[临界资源]
)临界区
)假如我们现在有一个抢票的场景:票是一个全局变量,然后现在我们创建了5个线程来抢票,那么在这个场景下会发生什么问题呢,下面我们来看一下代码
#include
#include
#include
using namespace std;
//抢票逻辑,1000张票,5个线程同时在抢
int tickets = 1000;
void* ThreadRoutine(void* args)
{
int id = *(int*)args;
delete (int*)args;
while(true)
{
//还有票
if(tickets>0)
{
//微秒
usleep(10000);
cout<< "我是[" << id <<" ] 我要抢的票是: "<<tickets--<<endl;
}
//没有票
else
{
break;
}
}
}
int main()
{
pthread_t tid[5];
for(int i = 0;i<5;i++)
{
int* id = new int(i);
pthread_create(tid+1,nullptr,ThreadRoutine,id);
}
for(int i = 0;i<5;i++)
{
pthread_join(tid[i],nullptr);
}
return 0;
}
运行结果:
我们发现这里只有总共1000张票,按理说抢到1,应该就停止抢票了,但是现在票变成了负数,线程却还在抢。 这种情况会导致在同一张票卖出去了几次,在我们实际购票的时候是绝对不允许这种情况出现的。
大家现在可能心里会有一个问题:为什么我们上面的代码没有达到我们预期的结果呢?
原因有以下几点:
这个时候可能就会有同学问了:为什么tickets–不是原子操作呢?
我们对一个变量进行–操作,我们其实需要执行以下三步:
tickets–操作的汇编代码如下:
movl ticket(%rip), %eax # 把ticket的值加载到eax寄存器中
subl $1, %eax # 把eax寄存器中的值减1
movl %eax, ticket(%rip) # 把现在eax寄存器中tickets的值写回到tickets的内存地址
因为我们的–操作需要三个步骤才能完成,那么就有可能出现这种情况:
一个线程刚刚把tickets的值加载进内存中,它就被切走了,我们知道线程切走会保存它的上下文信息。此时又有一个线程进来,因为第一个线程执行完第一步就被切走了,因此第二个线程看到的tickets还是1000,此时第二个线程一个人抢了5张票然后tickets变成了995.此时我们第一个线程又被CPU调度,因为保存了它的上下文信息,此时它就认为还有1000张票(但实际上只剩995张票了)然后它一个人抢了2张票,此时它会把最新的tickets(998)写回到内存,于是我们的票是本来是剩余995张,但是现在却由995->998这样就会导致我们上面一张票卖出了几次的问题。
因此我们就可以知道对一个变量进行++或者–操作它不是原子的。
那么问题来了:如何解决上面的问题呢?
要解决上面的问题,需要做到以下三点:
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量
初始化互斥量
初始化互斥量有以下两种方法:
静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
动态分配
初始化互斥量的函数叫做prhead_mutex_init
函数原型如下:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数说明:
返回值:
销毁互斥量
销毁互斥量的函数叫做pthread_mutex_destroy
函数原型如下:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数说明:
返回值:
注意:
PTHREAD_ MUTEX_ INITIALIZER
初始化的互斥量不需要销毁互斥量加锁
互斥量加锁的函数为pthread_mutex_lock
函数原型如下:
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数说明:
返回值:
在调用pthead_lock
时,我们可能会遇到以下情况:
互斥量解锁
互斥量加锁的函数为pthread_mutex_unlock
函数原型如下:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数说明:
返回值:
我们可以看到互斥量的接口有一个特点——都是以pthead_mutex
开头的后面再加上这个接口的功能的英文名,比如说初始化就加_Init,销毁就加上_destroy
下面我们使用这些互斥量的这些接口来改进一下上面的抢票代码吧
#include
#include
#include
using namespace std;
//抢票逻辑,1000张票,5个线程同时在抢
//定义一把互斥锁
pthread_mutex_t mtx;
int tickets = 1000;
void* ThreadRoutine(void* args)
{
int id = *(int*)args;
delete (int*)args;
while(true)
{
//微秒
usleep(5000);//我们这里改了一下位置,否则放里面的话有可能会导致一个线程抢了很多张票
//加锁
pthread_mutex_lock(&mtx);
//还有票
if(tickets>0)
{
//微秒
usleep(10000);
cout<< "我是[" << id <<" ] 我要抢的票是: "<<tickets--<<endl;
//解锁
pthread_mutex_unlock(&mtx);
}
//没有票
else
{
//解锁
pthread_mutex_unlock(&mtx);
break;
}
}
}
int main()
{
//对锁进行初始化
pthread_mutex_init(&mtx,nullptr);
pthread_t tid[5];
for(int i = 0;i<5;i++)
{
int* id = new int(i);
pthread_create(tid+1,nullptr,ThreadRoutine,id);
}
for(int i = 0;i<5;i++)
{
pthread_join(tid[i],nullptr);
}
//用完了销毁锁
pthread_mutex_destroy(&mtx);
return 0;
}
运行结果:
可以看到使用了互斥量之后,这里的抢票抢到1就不会再抢了,达到了我们的预期效果。
注意:
我们加锁之后,代码的执行效率一般会降低,这是为什么呢?
这是因为在大部分情况下,加锁本身都是有损于性能的事,它会让多执行流由并行执行变为了串行执行,这几乎是不可避免的。
代码是程序员写的!为了保证临界区的安全,必须保证每个线程都遵守相同的编码规范(A线程申请锁,其他线程的代码也必须申请锁)
我们的线程在访问临界区的代码之前都必须要先申请锁,那也就意味着所有的线程必须看到同一把锁,那锁是不是临界资源呢?
锁本身就是临界资源,我们通过锁去保护临界区, 那我们的锁需不需要保护自身的安全呢?
答案是当然需要的,这就好比你想保护一个女生,如果你自己都保护不了自己,那你就不可能去保护那个女生。
因此我们需要保证我们的锁本身是原子性
的,不能够出现中间态。
i++
或者++i
都不是原子的,有可能会由数据一致性问题下面我们来看一下lock和unlock的伪代码:
lock:
movb $0, %a1
xchgb %a1, mutex
if (%a1 > 0)
return 0;
else
挂起等待;
goto lock;
unlock:
movb $1 mutex
唤醒等待的线程;
return 0;
我们假设这里的mutex的初始值为1,然后a1是一个寄存器,当线程去申请锁时,需要执行以下几步:
下面我们通过图片来展示一下多个线程申请锁的过程:
现在有两个线程,分别叫做线程A、线程B,线程A与线程B刚开始都将a1寄存器的值置为0
此时线程A先将a1寄存器里面的值与mutex值交换,线程B再将a1寄存器里面的值与mutex值进行交换,因为A先将寄存器里面的值与mutex值交换了,所以等B进行交换的时候内存里面mutex里面的值已经变成0了,因此B交换后a1寄存器里面的值和内存里面mutex的值都是0.
假如此时线程A的时间片到了,然后线程A会被切走(被切走的时候线程A的上下文信息会被保存起来),此时线程B去申请锁,因为此时线程B它a1寄存器里面的值是小于0的,因此它申请不到锁所以他要挂起等待
此时CPU开始调度线程A继续执行,此时线程A申请锁,因为此时线程A它a1寄存器里面的值是大于0的,因此它能够申请到锁
当线程A快执行完自己的认为时,它释放刚刚申请到的锁,此时内存中的mutex值会被置成1,刚刚由于竞争锁失败而挂起等待的线程B此时会被唤醒然后去重新竞争锁。
以上就是互斥量的实现原理了,了解了上面的实现原理之后我们也就明白了,为什么当一个线程它申请到锁之后,即使它被切走了,但是其他的线程还是申请不到锁,这是因为即使它被切走了但是因为它的寄存器里面的值为1,所以也就相当于它是拿着锁走的,别人是拿不到这把锁的。
大家可能觉得死锁的四个必要条件不太好记,下面我来给大家讲一个小故事帮助大家记忆
现在有两个小女孩,我们分别称他们为小女孩A与小女孩B,两个小女孩手上都只有5毛钱,但是买一根棒棒糖却要1块钱,这个时候A对B说:B啊,你把你手上的5毛钱给我吧,我去买一根棒棒糖。B对A说:A啊,你把你手上的5毛钱给我吧,我去买一根棒棒糖。这个时候A向B要她5毛钱,B向A要她的5毛钱,但是两个人都不肯把自己的5毛钱给对方,因为两个人都是好朋友所以不会出现直接抢夺对方5毛球的情况,因此这就形成了死锁。
上面的这个小故事就体现了死锁的四个必要条件:
A和B各自有5毛钱(互斥条件
),两个人都向对方要对方的5毛钱,但是不肯将自己的5毛钱给对方(请求与保持条件
),因为两个人是好朋友所以不会出现直接抢夺对方5毛钱的情况(不剥夺条件
),于是两个人就形成一种头尾相接的循环等待资源的关系(循环等待条件
)
下面我们来看一段死锁的代码吧
#include
#include
#include
using namespace std;
pthread_mutex_t mtx1;//线程1的锁
pthread_mutex_t mtx2;//线程2的锁
void* Run1(void* arg)
{
char* msg = (char*)arg;
//加锁
pthread_mutex_lock(&mtx1);
while(true)
{
cout<<"我是"<<msg<<endl;
sleep(1);
//申请对方的锁
pthread_mutex_lock(&mtx2);
}
//解锁
pthread_mutex_unlock(&mtx1);
}
void* Run2(void* arg)
{
char* msg = (char*)arg;
//加锁
pthread_mutex_lock(&mtx2);
while(true)
{
cout<<"我是"<<msg<<endl;
sleep(1);
//申请对方的锁
pthread_mutex_lock(&mtx1);
}
//解锁
pthread_mutex_unlock(&mtx2);
}
int main()
{
pthread_t t1,t2;
pthread_mutex_init(&mtx1,nullptr);
pthread_mutex_init(&mtx2,nullptr);
pthread_create(&t1,nullptr,Run1,(void*)"thread 1");
pthread_create(&t2,nullptr,Run2,(void*)"thread 2");
pthread_join(t1,nullptr);
pthread_join(t2,nullptr);
pthread_mutex_destroy(&mtx1);
pthread_mutex_destroy(&mtx2);
return 0;
}
运行结果:
可以看到我们的代码运行起来之后死锁了。
同步
竞态条件
注意:
饥饿问题
概念: 用来描述某种临界资源是否就绪的一种数据化描述
条件变量通常需要配合mutex互斥锁一起使用
初始化条件变量
初始化条件变量的函数叫做——pthread_cond_init
函数原型如下:
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
参数说明:
返回值:
销毁条件变量
销毁条件变量的函数叫做——pthread_cond_destroy
函数原型如下:
int pthread_cond_destroy(pthread_cond_t *cond);
参数说明:
返回值:
等待条件满足
等待条件变量满足的函数叫做pthread_cond_wait
函数原型如下:
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
参数说明:
返回值:
唤醒等待
唤醒等待的函数有以下两个:
int pthread_cond_broadcast
与pthread_cond_signal
函数原型如下:
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
参数说明:
区别:
返回值:
注意:
pthread_cond_broadcast和pthread_cond_signal
前者是唤醒等待队列中所所有的线程,而后者只唤醒等待队列中的首个线程。前者会带来一个很不好的效应——
惊群效应
。多个线程同时被唤醒,但是最终只有一个线程能够获得“控制权”,其他获得控制权失败的线程可能重新进入休眠状态。等待获得控制权的线程释放锁资源后去通知下一个线程,这样就容易引起OS和CPU的管理调度负担,所以不建议使用。
知道了条件变量的函数之后,下面我们来看一段线程同步的代码吧
#include
#include
#include
#include
using namespace std;
pthread_mutex_t mtx;//锁
pthread_cond_t cond;//条件变量
void* Run(void* arg)
{
pthread_detach(pthread_self());
cout<<(char*)arg<<" run..."<<endl;
while(true)
{
pthread_cond_wait(&cond,&wait);//阻塞在这
cout<<(char*)arg<<"活动..."<<endl;
}
}
int main()
{
pthread_mutex_init(&mtx,nullptr);
pthread_cond_init(&cond,nullptr);
pthread_t t1,t2,t3;
pthread_ctreate(&t1,nullptr,Run,(void*)"thread 1");
pthread_ctreate(&t2,nullptr,Run,(void*)"thread 2");
pthread_ctreate(&t3,nullptr,Run,(void*)"thread 3");
while(true)
{
getchar();
pthread_cond_signal(&cond);//唤醒一个线程
}
pthread_mutex_destroy(&mtx);
pthread_cond_destroy(&cond);
return 0;
}
运行结果:
通过运行结果我们可以看到,我们唤醒的这三个线程是有顺序性的,主要是因为这几个线程启动时默认都会在该条件变量下去等待,而我们每次都唤醒的是在当前条件变量下等待的首个线程,当该线程执行完打印操作后会继续排到等待队列的尾部进行wait,所以我们能够唤醒的线程是有顺序性的。
如果我们想每次唤醒在该条件变量下等待的所有线程,我们只需要将pthread_cond_signal
函数改为pthread_cond_broadcast
函数即可。
#include
#include
#include
#include
using namespace std;
pthread_mutex_t mtx;//锁
pthread_cond_t cond;//条件变量
void* Run(void* arg)
{
pthread_detach(pthread_self());
cout<<(char*)arg<<" run..."<<endl;
while(true)
{
pthread_cond_wait(&cond,&wait);//阻塞在这
cout<<(char*)arg<<"活动..."<<endl;
}
}
int main()
{
pthread_mutex_init(&mtx,nullptr);
pthread_cond_init(&cond,nullptr);
pthread_t t1,t2,t3;
pthread_ctreate(&t1,nullptr,Run,(void*)"thread 1");
pthread_ctreate(&t2,nullptr,Run,(void*)"thread 2");
pthread_ctreate(&t3,nullptr,Run,(void*)"thread 3");
while(true)
{
getchar();
//pthread_cond_signal(&cond);//唤醒一个线程
pthread_cond_broadcast(&cond);//唤醒在该条件变量下等待的所有线程
}
pthread_mutex_destroy(&mtx);
pthread_cond_destroy(&cond);
return 0;
}
运行结果
可以看到我们这一次的唤醒是一次唤醒了所有在cond条件变量下等待的线程。
下面我们再来回答一个问题:
为什么phread_cond_wait需要互斥量
pthread_cond_wait
函数时,还需要将对应的互斥锁传入,此时当线程因为某些条件不满足需要在该条件变量下进行等待时,就会自动释放该互斥锁。注意:
pthread_cond_wait
函数有两个功能,一是让线程在特定的条件变量下进行等待,二是让线程释放掉自己申请到的互斥锁。当该线程被唤醒后,该线程会立马获得之前释放的互斥锁,然后继续向下执行。