目录
引言
随着信息技术的飞速发展,计算机软件正变得越来越复杂,对性能和响应速度的要求也日益提高。在这样的背景下,多线程编程作为一种提高程序执行效率和资源利用率的技术,已经成为了现代软件开发中的重要组成部分。然而,多线程技术在带来性能提升的同时,也引入了一系列复杂的问题,如资源共享、数据一致性和线程间的协调。为了确保多线程环境下的程序正确性和稳定性,线程同步与线程互斥成为了我们必须深入探讨的关键领域。上文讲解了用户级线程(linux是用户级线程,win是内核级线程)本文将引导读者了解多线程编程的基本概念,探讨线程同步与线程互斥的重要性,并分析如何在多线程应用程序中有效地管理和控制线程行为。
一、多线程设计
cpp
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <vector>
#include <string>
using namespace std;
class threadData
{
public:
threadData(const string& name)
:_threadname(name)
{}
public:
string _threadname;
};
void* threadRountine(void* arg)
{
pthread_detach(pthread_self()); //detach thread from main thread,但是不能保证主线程最后退出!
threadData* td = static_cast<threadData*>(arg);
int i = 0;
while (i < 5)
{
cout << td->_threadname << " ,test_i = " << i << ", &test_i = " << (&i)<< endl;
i++;
sleep(1);
}
delete td;
return nullptr;
}
int main()
{
vector<pthread_t> tids;
for (int i = 0; i < 5; i++)
{
pthread_t tid;
threadData* td = new threadData("Thread" + to_string(i));
pthread_create(&tid, nullptr, threadRountine, td);
tids.push_back(tid);
sleep(1);
}
return 0;
}
可以观察到,栈内的数据是线程独享的。**每个线程都有自己独立的栈结构.**地址不同,变量的数值也不同,同时哪个线程先执行也是未知的。
需要注意的是,我们观察到有的线程并没有跑完5个完整的循环,这是因为:pthread_detach函数不能保证主线程最后退出,但是pthread_join函数能保证主线程最后退出,如果主线程退出,那么进程就会退出。
****其实在线程当中没有秘密(都在一个地址空间)。****我们可以定义一个全局的变量,让线程中使用这个变量,并实例化这个变量,我们就可以在主线程、其他线程获得这个数据。
虽然可以,但是线程独立栈上的数据不要透露出去!---- -保证独立,但不能保证私有 !
即,如何让一个全局变量拥有线程私有的数值呢?----线程的局部存储技术
__thread:这是编译器支持的编译选项。只能定义内置类型,禁止自定义
发现地址并不一致。因此对g_val ++ 的时候,g_val属于线程私有。而且之前的地址很小,在全局区。现在的地址很大,就像是在堆栈一样大。
主函数:已初始化全局区 线程函数:共享区
用途:减少相似代码的多次编写
多线程模拟抢票
cpp
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <vector>
#include <string>
using namespace std;
int tickets = 100;
class threadData
{
public:
threadData(int number)
:_threadname("Thread" + to_string(number))
{}
public:
string _threadname;
};
void* GetTickets(void* arg)
{
threadData* td = static_cast<threadData*>(arg);
while (true)
{
if (tickets > 0)
{
usleep(10000);
cout << td->_threadname << " got a ticket : " << tickets << endl;
tickets--;
}
else
break;
}
cout << td->_threadname << " : quit" << endl;
return nullptr;
}
int main()
{
vector<pthread_t> tids;
vector<threadData*> tds;
for (int i = 0; i < 5; i++)
{
pthread_t tid;
threadData* td = new threadData(i);
tds.push_back(td);
pthread_create(&tid, nullptr, GetTickets, td);
tids.push_back(tid);
}
for (int i = 0; i < tids.size(); i++)
{
pthread_join(tids[i], nullptr); //原型:int pthread_join(pthread_t thread, void **retval);
delete tds[i];
}
return 0;
}
我们设置了100张tickets,让5个线程去抢票。
发现抢票的逻辑出错了。
原因:
1.显示器是一个共享资源,没有得到保护,各个线程抢占显示器资源,导致打印出错。
2.tickets是一个共享资源,多个线程并发访问的时候,会导致临界资源收不到保护。作为临界资源,必须保证一次只能被一个执行流进入访问**。并发访问是指多个执行单元(如线程、进程或任务)在同一时间段内试图同时访问共享资源(如内存、文件、数据库等)的情况**
为什么可能无法获得争取结果?
if 语句判断条件为真以后,代码可以并发的切换到其他线程
usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
--ticket 操作本身就不是一个原子操作
--操作需要三部才能完成
--操作不是原子的,不是安全的
二、互斥锁
互斥量的接口
初始化互斥量
初始化互斥量有两种方法:
方法 1 ,静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
方法 2 ,动态分配 :
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict
attr);
参数:
mutex :要初始化的互斥量
attr : NULL
pthread_mutex_t这是库给我们提供的一种数据类型
可以定义成全局,用这个宏初始化
定义成全局之后,不需要destroy销毁
不是全局,就需要借助函数初始化和销毁
初始化函数的第二个参数是属性相关,可以不用管,设置为nullptr
并发访问需要保证所有线程申请的是同一把锁。
销毁互斥量
销毁互斥量需要注意
使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
不要销毁一个已经加锁的互斥量
已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
调用 pthread_ lock 时,可能会遇到以下情况:
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
加锁和解锁之间的区域,就可以保证数据是安全的访问的
为了让tickets数据是安全的,在访问tickets数据的阶段就需要加锁。
我们成功加锁之后,这个tickets全局变量就变成了临界资源。
在我们的代码中,只有一小部分在访问临界资源,这一部分叫做临界区
互斥:在任何时刻,只让一个执行流去访问临界资源。
锁:拒绝并发,串行执行,保护数据的安全。
锁之间的代码是原子性的
/*
并发(Concurrency)
并发是指系统能够处理多个任务同时存在的能力。在并发的情况下,任务可以交替进行,即一个任务开始执行,在它执行的过程中,可以暂停并让另一个任务执行,然后第一个任务可以在适当的时候恢复执行。并发强调的是任务管理的灵活性,而不是速度。
并发的一些关键点包括:
****时间共享:****多个任务共享同一个处理器的执行时间。
上下文切换:操作系统可以在任务之间快速切换,某个时间段同时处理多个任务。
****协调:****并发系统需要处理任务之间的资源共享和通信问题。
并行(Parallelism)
并行是指同时执行多个任务的能力。并行执行通常需要多个处理器或多核处理器,这样每个处理器可以同时处理一个任务或任务的一部分(多个cpu,可以一次从执行队列调出多个线程去执行)。
并行的一些关键点包括:
****同时性:****多个任务或多个部分同时在不同的处理器上执行。
****硬件依赖:****并行执行通常需要特定的硬件支持,如多核CPU或分布式系统。
*/
修改抢票代码
增加上锁保护机制。
cpp
class threadData
{
public:
threadData(int number, pthread_mutex_t* mutex)
:_threadname("Thread" + to_string(number))
,_mutex(mutex)
{}
public:
string _threadname;
pthread_mutex_t* _mutex;
};
需要在任何形式的访问共享资源前加锁。抢完一次票需要解锁。
解锁需要在break之前,防止break之后直接跳出循环,这样最后一次循环中申请的锁就无法解锁。
现象
发现票都被两个线程抢完了,虽然不会出现tickets出错,但是出现了逻辑出错。
为什么不把锁放在while外部呢?---当然不可以
逻辑上:1.不可以把票一次性--完 2.增加了执行负担,降低并发效率
执行临界区的代码需要先申请锁,这个锁只有一把(所有线程只能看到同一把锁)。
申请锁成功才能去执行临界资源,否则执行流会在锁处 阻塞 。
申请锁失败:1.正常申请失败 2.锁被其他人拿走......这个时候线程会等待锁资源。
虽然票不是负数,但是被一个线程抢完了。
加上休眠时间
只要不访问临界区了,就可以解锁。
这样就可以保证规则地去抢锁。
/*
锁与时间片:当一个线程获得锁并开始执行被加锁的代码块时,它可能会占用一个或多个时间片。如果在执行过程中时间片用尽,操作系统可能会进行线程调度,将该线程挂起,并将CPU时间分配给其他线程。但是,即使发生线程切换,持有锁的线程在释放锁之前,其他线程是无法进入该锁保护的代码块的。
也就说,就算锁资源在执行的过程中因为时间消耗完毕而被切换,那么其他执行流在执行的时候 (线程是被执行调度的基本单位) ,也禁止访问"被锁住"的资源,从而保证资源的安全性,从而在现象上达成了原子性的现象
*/
全局锁:不需要初始化与销毁
(用全局的宏初始化)内部加锁就直接用全局的锁就可以。
即:省去了init与destory函数
但是还是得手动lock与unlock。
这个时候就不需要类内封装锁,使用全局的锁就可以
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
之前代码的错误:
锁被释放之后,那么在循环又接着去申请锁,导致不断地申请释放锁,从而导致资源一直被锁在一个执行流(线程)之中。
大白话就是:你这个线程离这个锁更近,每个线程竞争锁的能力是不同的。
当修改之后,这个进程在休眠期间,其他进程就有机会去竞争锁资源。
我们对于锁的申请是允许并发(一段时间内,各个执行流都有机会被执行)的,只不过上锁(lock)这句代码是原子的。
条件变量:线程同步解决饥饿问题
锁的原理
锁也是共享的。我们对于锁的申请是允许并发(一段时间内,各个执行流都有机会被执行)的,只不过上锁(lock)这句代码是原子的。
但是
该部分将着重解决两个问题,第一个问题就是锁的原理问题。
问题2
答案是可以切换的
这是锁通过对线程的控制完成的。
问题一解答:锁的原理
经过上面的例子,大家已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我
们把lock和unlock的伪代码改一下
上锁:
1.先把al寄存器置0
2.再把al和mutex的数据做交换。
如果al寄存器中的数据是1--得到了锁,那么就会申请锁结束
如果al寄存器中的数据是0,代码就阻塞在这个地方,持续申请锁,知道锁的到来。
解锁:
锁被申请到之后,mutex内部就存储的是0。解锁只需要让mutex重新存储1即可,这样其他线程就能再次申请锁。
唤醒其他线程,让锁的内容从0变1(上锁经历了xchg之后,锁的内容为0),这样就恢复到了锁初始化的状态,让其他线程有能力申请锁。
锁的封装:RAII
cpp
#pragma once
#include <pthread.h>
class Mutex
{
public:
Mutex(pthread_mutex_t* lock)
: _lock(lock)
{}
void Lock()
{
pthread_mutex_lock(_lock);
}
void Unlock()
{
pthread_mutex_unlock(_lock);
}
~Mutex()
{}
private:
pthread_mutex_t* _lock;
};
class LockGuard // RAII class for locking and unlocking a mutex---需要传入指针
{
public:
LockGuard(pthread_mutex_t* mutex)
: _mutex(mutex)
{
_mutex.Lock();
}
~LockGuard()
{
_mutex.Unlock();
}
private:
Mutex _mutex;
};
在使用的时候,我们应该在外面建立一个锁的指针,就像是智能指针一样。
注意:
1,限定作用域,出了作用域之后,这个临时对象会自动调用析构去解锁
2,业务的处理时间不应该在if内,不应该在加锁内部。
发现正确抢完了票。
由于打印在锁的内部,所以显示器资源也被保护了。