目录
Linux 线程互斥
进程线程间互斥相关背景和概念
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
比如下面这段代码中,g_val作为一个全局变量,主线程和新创建出的线程都能访问到它,被多个执行流所共享,所以是临界资源。
新线程中g_val- -和主线程中打印了g_val ,都访问了临界资源的代码,所以就叫做临界区。
//code1
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int g_val = 10000;//临界资源
void* fun(void* args)
{
while(1)
{
g_val--;//临界区
sleep(1);
}
pthread_exit((void*) 0);
}
int main()
{
pthread_t t1;
pthread_create(&t1,nullptr,fun,nullptr);
while(1)
{
cout << "g_val = " << g_val << endl;//临界区
sleep(1);
}
pthread_join(t1,nullptr);
return 0;
}
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
在多线程的情况下,如果任由各个线程并发对临界资源进行修改的操作,就有可能导致临界资源不能达到我们预期的要求。
比如下面这段代码演示:
//code2
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int g_val = 10;
void* fun(void* args)
{
string name = static_cast<const char*>(args);
while(1)
{
if(g_val > 0)
{
sleep(1);
g_val--;
cout << name << ": " << g_val << endl;
}
else
{
break;
}
}
pthread_exit((void*) 0);
}
int main()
{
pthread_t t1;
pthread_t t2;
pthread_create(&t1,nullptr,fun,(void*)"thread-1");
pthread_create(&t2,nullptr,fun,(void*)"thread-2");
pthread_join(t1,nullptr);
pthread_join(t2,nullptr);
return 0;
}
code2 演示了两个线程对一个全局变量g_val进行 - - 操作,每次打印g_val的值。按照我们的预期,它应该是打印到0程序就结束了。然而并不是。g_val的值出现的负数的情况。
出现负数的原因:
- 在一个线程中,if语句判断条件为真后,代码中有sleep语句,此时其他线程可能会趁机进入该代码段。
- g_val- -操作不是一个原子操作
前面我们讲到,原子性是不会被操作系统的任何调度打断的,也就说原子操作只有两种状态,要么完成了,要么未完成。
为什么g_val不是原子操作?
g_val - - 在C++语言层面上虽然只是一条语句,但是汇编语言才是计算机执行的语言,而对变量进行 - - ,实际需要以下三个步骤
- load:将共享变量tickets从内存加载到寄存器中。
- update:更新寄存器里面的值,执行-1操作。
- store:将新值从寄存器写回共享变量tickets的内存地址
**- -**操作的汇编代码如下:
既然- - 操作实际需要三个步骤才能完成,那么就有可能在thread-1把g_val的值读进CPU寄存器然后进行 - 1操作的时候就被CPU调度切走了,并没有把值写回到内存。假设此时thread-1读取到g_val的值是1,-1操作后,当thread-1被切走时,寄存器中的数据就叫做thread-1的上下文信息,thread-1需要将它保存起来,之后就挂起了,等待下一次调度。
假设此时CPU调度了thread-2,thread-2 判断此时g_val还是 1 ,所以进入if语句代码块里面,当还没开始进行 - - 操作时,如果因为时间片比较短,此时又切换到了thread-1,此时thread-1恢复上下文信息到CPU寄存器,会接着执行上一次还没完成的指令,于是就把0写回到了内存。
此时thread-1如果还未被切走,因为判断都g_val == 0 不满足 if 条件了,所以进入else,thread-1 结束。
重要的地方来了,此时CPU调度切回thread-2,由于thread是上次是已经满了if条件的,但还没开始- - 操作,所以才正式开始- - 操作,从内存中读数据到寄存器,是0,然后-1,最后把-1写回到内存。所以就会出现g_val为-1的情况。
出现负数这种情况只是线程不安全的情况之一,由于- - 操作不是原子的,在三步汇编指令中任意一步都可能被切走,也会出现其他的情况。
比如当thread-1的g_val的值为10时,执行完- - 操作汇编指令第二步把g_val变为9后,就被切走了,并没有写回到内存,thread-2被调度了,thread-2一直将g_val的值减到1,并写回到内存。此时thread-1回来了,接着执行上次的步骤,把9写回到内存,此时g_val又变为9了,thread-2做的工作白费了。如果应用到现实业务中,会出现很严重的问题。
互斥量
- 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
- 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之 间的交互。
- 多个线程并发的操作共享变量,会带来一些问题。
要解决以上问题,需要做到三点:
- 代码必须有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
互斥量的接口
初始化互斥量
初始化互斥量有两种方法:
- 静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
- 动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:
mutex:要初始化的互斥量
attr:初始化互斥量的属性,一般设置为NULL即可。
返回值说明:
互斥量初始化成功返回0,失败返回错误码
销毁互斥量
销毁互斥量需要注意:
- 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数说明:
mutex:需要销毁的互斥量
返回值说明:
互斥量销毁成功返回0,失败返回错误码。
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex); //加锁
int pthread_mutex_unlock(pthread_mutex_t *mutex); //解锁
参数说明:
mutex:需要加锁的互斥量。
返回值说明:
互斥量加锁/解锁成功返回0,失败返回错误码。
调用 pthread_ mutex_lock 时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量, 那么pthread_mutex_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
使用示例:
//code3
#include <iostream>
#include <unistd.h>
#include <pthread.h>
using namespace std;
int g_val = 10;
pthread_mutex_t mutex;//定义一把全局的锁
void* fun(void* args)
{
string name = static_cast<const char*>(args);
while(1)
{
usleep(1000);//休眠的代码放在这里模拟效果比较好,不然会出现一个线程抢光所有票的现象
pthread_mutex_lock(&mutex);//加锁
if(g_val > 0)
{
g_val--;
cout << name << ": " << g_val << endl;
pthread_mutex_unlock(&mutex);//解锁
}
else
{
pthread_mutex_unlock(&mutex);//解锁
break;
}
}
pthread_exit((void*) 0);
}
int main()
{
pthread_mutex_init(&mutex,nullptr);
pthread_t t1;
pthread_t t2;
pthread_create(&t1,nullptr,fun,(void*)"thread-1");
pthread_create(&t2,nullptr,fun,(void*)"thread-2");
pthread_join(t1,nullptr);
pthread_join(t2,nullptr);
pthread_mutex_destroy(&mutex);
return 0;
}
执行结果:
注意:
- 在大部分情况下,加锁本身都是有损于性能的事,它让多执行流由并行执行变为了串行执行,这几乎是不可避免的。
- 我们应该在合适的位置进行加锁和解锁,这样能尽可能减少加锁带来的性能开销成本。
- 进行临界资源的保护,是所有执行流都应该遵守的标准,这是程序员在编码时需要注意的。
互斥量实现原理探究
为什么加了锁就能体现出原子性?
引入互斥量后,当一个线程申请锁进入到临界区时,在其他线程看来,要么没有申请锁,要么锁已经释放了。只有这两种状态对其他线程才是有意义的,只关心什么时候自己才能拿到锁。
例如,图中线程1进入临界区后,在线程2,3,4看来,线程1要么没有申请锁,要么锁已经释放了,只关心自己什么时候才能拿到锁,如果检测到其他状态(如该锁已经被线程1拿到了),自己只能处于阻塞状态,等待下一次竞争锁。
此时对于线程2,3,4而言,它们就认为线程1的操作是原子的。
临界区内的线程可能进行线程切换吗?如果切换了会影响到当前锁吗?
临界区的线程是可能线程切换去执行其他任务的,但是即使该线程被切走,其他线程也无法进入临界区进行资源访问,我们可以看做该线程是拿着锁被切走的,锁没用释放,也就意外着其他线程没用机会申请到锁,也就无法进入临界区进行资源访问了。
上面定义的锁也是一个全局对象,意味着它也是一个临界资源,它需要被保护吗?
锁既然是临界资源,那么它就必须被保护。可是锁的创造初心就是为了保护临界资源,那么谁来保护锁?
锁实际上是自己保护自己的,因为申请锁的过程本身就是原子的,所以锁是线程安全的。
申请锁如何保证原子性?
- 上面我们已经说明了- -操作不是原子操作,那么++也不是原子操作,不是原子操作,可能会导致数据不一致问题。
- 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相交换。由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。
下面我们来看看lock和unlock的伪代码:
我们可以认为mutex的初始值为1,al是计算机中的一个寄存器,当线程申请锁时,需要执行以下步骤:
- 先将al寄存器中的值清0。该动作可以被多个线程同时执行,因为每个线程都有自己的一组寄存器(上下文信息),执行该动作本质上是将自己的al寄存器清0。
- 然后交换al寄存器和mutex中的值。xchgb是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换。
- 最后判断al寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。
例如,此时内存中mutex的值为1,线程申请锁时先将al寄存器中的值清0,然后将al寄存器中的值与内存中mutex的值进行交换。
交换完成后检测该线程的al寄存器中的值为1,则该线程申请锁成功,可以进入临界区对临界资源进行访问。
而此后的线程若是再申请锁,与内存中的mutex交换得到的值就是0了,此时该线程申请锁失败,需要被挂起等待,直到锁被释放后再次竞争申请锁。
当线程释放锁时,需要执行以下步骤:
- 将内存中的mutex置回1。使得下一个申请锁的线程在执行交换指令后能够得到1,形象地说就是“将锁的钥匙放回去”。
- 唤醒等待Mutex的线程。唤醒这些因为申请锁失败而被挂起的线程,让它们继续竞争申请锁。
注意:
- 在申请锁时本质上就是哪一个线程先执行了交换指令,那么该线程就申请锁成功,因为此时该线程的al寄存器中的值就是1了。而交换指令就只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行交换指令,所以申请锁的过程是原子的。
- 在线程释放锁时没有将当前线程al寄存器中的值清0,这不会造成影响,因为每次线程在申请锁时都会先将自己al寄存器中的值清0,再执行交换指令。
- CPU内的寄存器不是被所有的线程共享的,每个线程都有自己的一组寄存器,但内存中的数据是各个线程共享的。申请锁实际就是,把内存中的mutex通过交换指令,原子性的交换到自己的al寄存器中。
Linux 线程同步
同步概念与竞态条件
同步: 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫做同步。
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。
- 首先需要明确的是,单纯的加锁是会存在某些问题的,如果个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题。
- 单纯的加锁是没有错的,它能够保证在同一时间只有一个线程进入临界区,但它没有高效的让每一个线程使用这份临界资源。
- 现在我们增加一个规则,当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后。
- 增加这个规则之后,下一个获取到锁的资源的线程就一定是在资源等待队列首部的线程,如果有十个线程,此时我们就能够让这十个线程按照某种次序进行临界资源的访问。
例如,现在有两个线程访问一块临界区,一个线程往临界区写入数据,另一个线程从临界区读取数据,但负责数据写入的线程的竞争力特别强,该线程每次都能竞争到锁,那么此时该线程就一直在执行写入操作,直到临界区被写满,此后该线程就一直在进行申请锁和释放锁。而负责数据读取的线程由于竞争力太弱,每次都申请不到锁,因此无法进行数据的读取,引入同步后该问题就能很好的解决。
条件变量
这里只是简单地使用条件变量,可以结合生产者消费者模型看一下
生产者消费者模型
条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。
条件变量主要包括两个动作:
- 一个线程等待条件变量的条件成立而被挂起。
- 另一个线程使条件成立后唤醒等待的线程。
条件变量通常需要配合互斥锁一起使用。
条件变量函数
初始化条件变量
初始化条件变量的函数叫做pthread_cond_init,该函数的函数原型如下:
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
参数说明:
cond:需要初始化的条件变量。
attr:初始化条件变量的属性,一般设置为NULL即可。
返回值说明:
条件变量初始化成功返回0,失败返回错误码。
调用pthread_cond_init函数初始化条件变量叫做动态分配,除此之外,我们还可以用下面这种方式初始化条件变量,该方式叫做静态分配:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
销毁条件变量
销毁条件变量的函数叫做pthread_cond_destroy,该函数的函数原型如下:
int pthread_cond_destroy(pthread_cond_t *cond);
参数说明:
cond:需要销毁的条件变量。
返回值说明:
条件变量销毁成功返回0,失败返回错误码。
销毁条件变量需要注意:
使用PTHREAD_COND_INITIALIZER初始化的条件变量不需要销毁。
等待条件变量满足
等待条件变量满足的函数叫做pthread_cond_wait,该函数的函数原型如下:
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
参数说明:
cond:需要等待的条件变量。
mutex:当前线程所处临界区对应的互斥锁。
返回值说明:
函数调用成功返回0,失败返回错误码。
唤醒等待
唤醒等待的函数有以下两个:
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
区别:
pthread_cond_signal函数用于唤醒等待队列中首个线程。
pthread_cond_broadcast函数用于唤醒等待队列中的全部线程。
参数说明:
cond:唤醒在cond条件变量下等待的线程。
返回值说明:
函数调用成功返回0,失败返回错误码。
使用示例:
以下新创建了三个线程,然后让主线程控制这三个线程的活动,这三个线程创建后都在条件变量下进行等待,直到主线程唤醒。
#include <iostream>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
using namespace std;
pthread_cond_t cond;
pthread_mutex_t mutex;
void *r1(void *arg)
{
string name = static_cast<const char*> (arg);
while (1)
{
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond, &mutex);
cout << name << " 活动" << endl;
pthread_mutex_unlock(&mutex);
}
}
int main(void)
{
pthread_t t1, t2,t3;
pthread_cond_init(&cond, NULL);
pthread_mutex_init(&mutex, NULL);
pthread_create(&t1, NULL, r1, (void*)"thread-1");
pthread_create(&t2, NULL, r1, (void*)"thread-2");
pthread_create(&t3, NULL, r1, (void*)"thread-3");
while(1)
{
cout << "主线程唤醒" << endl;
pthread_cond_signal(&cond);
//pthread_cond_broadcast(&cond);
sleep(1);
}
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
}
此时我们会发现唤醒这三个线程时具有明显的顺序性,根本原因是当这若干个线程启动时默认都会在该条件变量下去等待,而我们每次都唤醒的是在当前条件变量下等待的头部线程,当该线程执行完打印操作后会继续排到等待队列的尾部进行wait,所以我们能够看到一个周转的现象。
如果我们想每次唤醒都将在该条件变量下等待的所有线程进行唤醒,可以将代码中的pthread_cond_signal函数改为pthread_cond_broadcast函数。
此时我们每一次唤醒都会将所有在该条件变量下等待的线程进行唤醒,也就是每次都将这三个线程唤醒。
为什么pthread_cond_wait需要互斥量
- 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
- 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据。
- 当线程进入临界区时需要先加锁,然后判断内部资源的情况,若不满足当前线程的执行条件,则需要在该条件变量下进行等待,但此时该线程是拿着锁被挂起的,也就意味着这个锁再也不会被释放了,此时就会发生死锁问题。
- 所以在调用pthread_cond_wait函数时,还需要将对应的互斥锁传入,此时当线程因为某些条件不满足需要在该条件变量下进行等待时,就会自动释放该互斥锁。
- 当该线程被唤醒时,该线程会接着执行临界区内的代码,此时便要求该线程必须立马获得对应的互斥锁,因此当某一个线程被唤醒时,实际会自动获得对应的互斥锁。
总结一下:
- 等待的时候往往是在临界区内等待的,当该线程进入等待的时候,互斥锁会自动释放,而当该线程被唤醒时,又会自动获得对应的互斥锁。
- 条件变量需要配合互斥锁使用,其中条件变量是用来完成同步的,而互斥锁是用来完成互斥的。
- pthread_cond_wait函数有两个功能,一就是让线程在特定的条件变量下等待,二就是让线程释放对应的互斥锁。
错误的设计
你可能会想:当我们进入临界区上锁后,如果发现条件不满足,那我们先解锁,然后在该条件变量下进行等待不就行了。
//错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false){
pthread_mutex_unlock(&mutex);
//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
pthread_cond_wait(&cond);
pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
但这是不可行的,因为解锁和等待不是原子操作,调用解锁之后,在调用pthread_cond_wait函数之前,如果已经有其他线程获取到互斥量,发现此时条件满足,于是发送了信号,那么此时pthread_cond_wait函数将错过这个信号,最终可能会导致线程永远不会被唤醒,因此解锁和等待必须是一个原子操作。
而实际进入pthread_cond_wait函数后,会先判断条件变量是否等于0,若等于0则说明不满足,此时会先将对应的互斥锁解锁,直到pthread_cond_wait函数返回时再将条件变量改为1,并将对应的互斥锁加锁。
条件变量使用规范
等待条件变量的代码
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(&cond, &mutex);
修改条件
pthread_mutex_unlock(&mutex);
唤醒等待线程的代码
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
线程安全与重入概念
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
死锁
- 死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
死锁四个必要条件
以下四个必要条件必须同时存在,才会造成死锁。
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁
避免死锁,破坏死锁的四个必要条件之一即可。
- 不加锁
- 主动释放锁
- 控制线程统一释放锁
- 按照顺序申请锁