在多线程操作中,因为线程共享虚拟地址空间,所以定义在全局的代码和数据,对于线程来说,都是共享的。
而CPU给予每个线程的时间片都是一定的。可能出现一个线程对于共享数据的修改还没完成,时间片就到达了,换成另一个线程。而另一个线程可能也会对共享数据作修改,这样就会影响上一个线程未完成的操作,或者事后被影响。所以多线程操作,需要相互独立,相互排斥
。
首先,我们需要有一些知识储备。
临界资源
:多线程执行流共享的资源就叫做临界资源临界区
:每个线程内部,访问临界资源的代码,就叫做临界区互斥
:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用原子性
:即两态性。就如同二进制一般,要么1,要么0,只有两种状态。原子操作是不会被任何调度机制打断的操作,该操作只有两态,要么执行完成,要么没执行。接下来,我们举例多线程操作经典的抢票问题:
#include
#include
#include
using namespace std;
//票数
int tickets=50;
//模拟抢票
void* ThreadRoutine(void* args)
{
string name=static_cast<const char*>(args);
//一直抢票
while(true)
{
if(tickets>0)
{
//如果票大于0,就可以抢
usleep(2000);
cout<<name<< " got the ticket: "<<tickets--<<endl;
}
else
{
//小于等于0代表抢完了
break;
}
}
return nullptr;
}
int main()
{
//创建4个线程模拟抢票
pthread_t th[4];
int num=sizeof(th)/sizeof(th[0]);
for(int i=0;i<num;++i)
{
//给每个线程命名
char *name=new char[64];
snprintf(name,64,"thread_%d",i+1);
//创建线程
pthread_create(th+i,nullptr,ThreadRoutine,name);
}
//线程等待
for(int i=0;i<num;++i)
{
pthread_join(th[i],nullptr);
}
return 0;
}
运行结果如下:
票数最后抢到了负数,这显然是不合理的
出现这种现象的原因正是因为多线程并发运行:
假设线程A运行时,判断现在票数为1,进行抢票,但是在抢票前,时间片到达,不得不切换成其他线程。
而其他线程看到票数大于0,也纷纷进行抢票,抢完票,票数为0。切换回线程A,A已经完成判断,所以只需要让票数-1,所以就导致最终票数出现了小于0的情况。
同时,在抢票过程中,多个操作都不是原子性的,都可能进行线程的切换
就比如说 ticket-1
这一操作,其本身在汇编层面对应三条指令
load
:将共享变量ticket从内存加载到寄存器中update
: 更新寄存器里面的值,执行-1操作store
:将新值,从寄存器写回共享变量ticket的内存地址单独的指令是原子性的,但是多条指令,就不是原子性的了。
要解决上述问题,我们需要做到以下三点:
互斥行为
:当代码进入临界区执行时,不允许其他线程进入该临界区只能允许一个线程进入该临界区
。临界区就像一个房间,里面的东西是临界资源。而为了保证同一时间只能一个人使用这间房间。需要给这个房间加上一把锁,并且配备唯一的钥匙,只有抢到钥匙的人才有房间的使用权。每个人在使用房间时,都从内反锁,防止其他人打扰。
在Linux中,这把锁叫作互斥量
Linux的互斥量是pthread_mutex_t
有两种初始化方式
静态分配
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
PTHREAD_MUTEX_INITIALIZER是线程库定义的一个宏
动态分配
int pthread_mutex_init(pthread_mutex_t *mutex,const pthread_mutexattr_t *attr);
mutex
:要初始化的互斥量
attr
:互斥量的属性,一般传nullptr,代表使用默认属性
互斥量不使用时,需要销毁
销毁互斥量的函数是:
int pthread_mutex_destroy(pthread_mutex_t * mutex);
mutex:销毁的互斥量
需要注意的是:
PTHREAD_MUTEX_INITIALIZER
初始化的互斥量不需要手动销毁已经加锁但未解锁
的互斥量接下来是加锁和解锁的函数
加锁
pthread_mutex_lock(pthread_mutex_t *mutex);
mutex:加锁的互斥量
注意事项
:
互斥量处于未锁状态
,该函数会将互斥量锁定,同时返回成功
发起函数调用时,如果其他线程已经锁定互斥量
,或者存在有其他线程同时申请互斥量,但自己没有竞争到互斥量,那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起)
,等待互斥量解锁
解锁
pthread_mutex_unlock(pthread_mutex_t *mutex);
mutex:解锁的互斥量
我们更改一下抢票系统,加上互斥量。
PS:加锁后还必须解锁
#include
#include
#include
using namespace std;
//票数
int tickets=1000;
//静态分配
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
//模拟抢票
void* ThreadRoutine(void* args)
{
string name=static_cast<const char*>(args);
while(true)
{
//加锁
pthread_mutex_lock(&mutex);
if(tickets>0)
{
//如果票大于0,就可以抢
cout<<name<< " got the ticket: "<<tickets--<<endl;
usleep(10000);
//解锁
pthread_mutex_unlock(&mutex);
}
else
{
//小于等于0代表抢完了
//解锁
pthread_mutex_unlock(&mutex);
break;
}
}
return nullptr;
}
int main()
{
//创建4个线程模拟抢票
pthread_t th[4];
int num=sizeof(th)/sizeof(th[0]);
for(int i=0;i<num;++i)
{
//给每个线程命名
char *name=new char[64];
snprintf(name,64,"thread_%d",i+1);
//创建线程
pthread_create(th+i,nullptr,ThreadRoutine,name);
}
//线程等待
for(int i=0;i<num;++i)
{
pthread_join(th[i],nullptr);
}
return 0;
}
我们在判断票数数量前加上锁,在抢完票和票被抢完后解锁
同时,我们增加票数,这样才可以观测到多个线程抢票。如果票较少,基本都是一个线程完抢完了。
运行结果如下:
多次运行都没有出现票数小于等于0的情况。
互斥量的使用还有以下细节:
同一个临界资源
的线程,都要进行加锁保护,而且必须加同一把锁
。每一个线程访问临界区之前,需要加锁
,加锁本质是给临界区加锁,加锁的粒度要尽量细一些
。因为对于临界区的访问,同时只能一个线程,所以整体效率肯定降低。加锁的粒度细,可以减少效率的降低。其实加锁和解锁本身就是原子性的,所以不用担心。
临界区可以是一行代码,可以是一批代码
加锁不代表当前访问临界资源的线程不会被切换
。就像上述所讲的房间的例子。在我拥有房间钥匙时,我也可能会出房间,但此时如果我的事情还没做完,还没到归还钥匙的时候(解锁),即使我离开房间,也会给房间重新上锁,防止他人使用房间加锁并不代表当前访问临界资源的线程不会切换,那么加锁是如何实现的?又是如何保证原子操作的?
接下来,我们进行解释。
首先看这样一组汇编指令。lock是加锁,unlock是解锁
动作是这样的
线程的相关运算需要将数据加载到寄存器中,但是寄存器对于每个线程都是可见的,所以每个线程的切换都需要将寄存器内的数据取走,不妨碍到下个线程的运行
mutex是在内存中的一个1
%al是寄存器
而当一个线程切换时间片时,需要将寄存器中所有的数据带走。
所以,假如线程A比线程B更快尝试申请锁,那么就会先交换到内存中的mutex,也就是1。
而此时就算线程A被切换,线程B尝试申请锁,也申请不了。因为唯一的1,在线程A当中
解锁
而解锁实现的简单很多,只要重新往内存中写入1就可以了。
但是为什么不是将原来的1拿回来呢?
这是因为解锁并不是只能加锁的线程执行,其他线程也可以解锁,但是因为线程之间不可访问各自的资源,所以其他线程无法拿回1,所以设计成重新往内存中写入1。
感谢你的阅读
如果觉得本篇文章对你有所帮助的话,不妨点个赞支持一下博主,拜托啦,这对我真的很重要。