目录
4.2.3 为什么pthread_cond_wait需要互斥量
一、线程互斥
1.1 相关概念介绍
- 临界资源: 多线程执行流共享的资源叫做临界资源
- 临界区: 每个线程内部访问临界资源的代码,被称为临界区
- 互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区访问临界资源,通常对临界资源起保护作用
- 原子性: 不会被任何调度机制打断的操作,该操作只有两态:要么完成,要么未完成
下面模拟实现一个抢票系统,将记录票的剩余张数的变量定义为全局变量,主线程创建四个新线程进行抢票,当票被抢完后这四个线程自动退出
#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
using namespace std;
const int thread_num = 4;
int tickets = 1000;
void* GetTickets(void* args) {
while (true) {
if (tickets > 0) {
usleep(10000);//抢票所耗费的时间
printf("[%s] get a ticket, left: %d\n", (char*)args, --tickets);
}
else {
break;
}
}
printf("%s quit!\n", (char*)args);
pthread_exit((void*)0);
}
int main()
{
pthread_t tids[thread_num];
pthread_create(tids, nullptr, GetTickets, (void*)"thread 1");
pthread_create(tids + 1, nullptr, GetTickets, (void*)"thread 2");
pthread_create(tids + 2, nullptr, GetTickets, (void*)"thread 3");
pthread_create(tids + 3, nullptr, GetTickets, (void*)"thread 4");
for(int i = 0;i < thread_num; ++i) {
pthread_join(tids[i], nullptr);
}
return 0;
}
运行结果显然不符合预期,最终票数变为了负数
票数为负原因:
- if语句判断条件为真以后,代码可以切换到其他线程,usleep用于模拟漫长业务的过程,在这个业务过程中可能有线程会进入该代码段
- --tickets操作并不是原子的
--tickets操作
对一个变量进行--,实际需要三个步骤:
- load:将共享变量tickets从内存加载到寄存器中
- update:更新寄存器里面的值,执行-1操作
- store:将新值从寄存器写回共享变量tickets的内存地址
-- 操作对应的汇编代码如下:
-- 操作需要三个步骤才能完成,有可能当thread1刚把tickets的值读进CPU寄存器就被切走了,假设此时thread1读取到的值是1000,而当thread1被切走时,寄存器中的1000叫做thread1的上下文数据,因此需要被保存起来,之后thread1就被挂起了
假设此时thread2被调度,由于thread1只执行了 -- 操作的第一步,因此thread2此时在内存中看到tickets的值仍是1000,假设系统给thread2的时间片可能较多,thread2一次性执行了100次 -- 操作才被切走,最终tickets由1000减到了900
此时系统再把thread1恢复上来,继续执行thread1的代码并且将thread1曾经的硬件上下文信息恢复,此时寄存器当中的值是恢复出来的1000,然后thread1继续执行 -- 操作的第二步和第三步,最终将999写回内存
此时,thread1抢了1张票,thread2抢了100张票,而此时剩余的票数却是999,也就相当于多出了100张票。 -- 操作并不是原子的,虽然--tickets看起来就是一行代码,但这行代码被编译器编译后本质上是三行汇编;相反,对一个变量进行++也需要对应的三个步骤,即++操作也不是原子操作
1.2 互斥量mutex
若线程使用的数据是局部变量,变量的地址空间在线程栈空间内,变量归属单个线程,其他线程无法获得这种变量;但有些变量需要在线程间共享(共享变量),可以通过数据的共享,完成线程之间的交互。多个线程并发的操作共享变量就会带来一些问题
要解决上述抢票系统的问题,需要做到三点:
- 代码必须有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 若多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区
- 若线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区
这时就需要一把锁,Linux中提供的这把锁被称为互斥量
1.3 互斥量接口
1.3.1 初始化互斥量
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:
- mutex:需要初始化的互斥量的地址
- attr:初始化互斥量的属性,一般设置为nullptr即可
返回值:互斥量初始化成功返回0,失败返回错误码
使用pthread_mutex_init()函数初始化互斥量的方式被称为动态分配,还可以使用静态分配进行初始化,即下面这种方式:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
1.3.2 销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数mutex:需要销毁的互斥量的地址
返回值:互斥量销毁成功返回0,失败返回错误码
注意:
- 使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
1.3.3 互斥量加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数mutex:需要加锁的互斥量的地址
返回值:互斥量加锁成功返回0,失败返回错误码
注意:
- 互斥量处于未锁状态时,该函数会将互斥量锁定,同时返回成功
- 发起函数调用时,若其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么线程会在pthread_mutex_lock()函数内部阻塞至互斥量解锁
1.3.4 互斥量解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数mutex:需要解锁的互斥量的地址
返回值:互斥量解锁成功返回0,失败返回错误码
1.3.5 使用案例
在上述的抢票系统中引入互斥量,以解决打印错乱和票数为负的问题:
#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
using namespace std;
const int thread_num = 4;
int tickets = 1000;
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
void* GetTickets(void* args) {
while (true) {
pthread_mutex_lock(&mtx);
if (tickets > 0) {
usleep(1000);//抢票所耗费的时间
printf("[%s] get a ticket, left: %d\n", (char*)args, --tickets);
pthread_mutex_unlock(&mtx);
usleep(10);//避免全部为同一线程抢占锁
}
else {
pthread_mutex_unlock(&mtx);
break;
}
}
printf("%s quit!\n", (char*)args);
pthread_exit((void*)0);
}
int main()
{
pthread_t tids[thread_num];
pthread_create(tids, nullptr, GetTickets, (void*)"thread 1");
pthread_create(tids + 1, nullptr, GetTickets, (void*)"thread 2");
pthread_create(tids + 2, nullptr, GetTickets, (void*)"thread 3");
pthread_create(tids + 3, nullptr, GetTickets, (void*)"thread 4");
for(int i = 0;i < thread_num; ++i) {
pthread_join(tids[i], nullptr);
}
return 0;
}
- 在大部分情况下,加锁本身都是有损于性能的,它让多执行流由并行执行变为了串行执行,这几乎是不可避免的
- 应该在合适的位置进行加锁和解锁,减小锁的粒度,可以减少加锁带来的性能开销成本
- 进行临界资源的保护,是所有执行流都应该遵守的标准,程序员在编码时需要注意
1.4 互斥量实现原理
加锁后的原子性如何体现?
引入互斥量后,当一个线程申请到锁进入临界区时,在其他线程看来该线程只有两种状态,要么没有申请锁,要么锁已经释放了,因为只有这两种状态对其他线程才是有意义的。
例如,图中线程1进入临界区后,在线程2、3、4看来,线程1要么没有申请锁,要么线程1已经将锁释放了,因为只有这两种状态对线程2、3、4才是有意义的,当线程2、3、4检测到其他状态(线程1持有锁)时也就被阻塞了。此时对于线程2、3、4而言,线程1的整个操作过程是原子的
临界区内的线程可能被切换吗?
临界区内的线程是可能进行线程切换。但即便该线程被切走,其他线程也无法进入临界区进行资源访问,因为此时该线程是拿着锁被切走的,锁没有被释放也就意味着其他线程无法申请到锁,也就无法进入临界区进行资源访问了。
其他想进入该临界区进行资源访问的线程,必须等该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进入临界区。
互斥锁是否需要被保护?
多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。
既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?
锁实际上是自己保护自己的,只需要保证申请锁的过程是原子的,那么锁就是安全的
如何保证申请锁是原子的?
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相交换。由于只有一条指令,保证了原子性。
lock和unlock的伪代码:
可以认为mutex的初始值为1,al是计算机中的一个寄存器
当线程申请锁时,需要执行以下步骤:
- 先将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内的寄存器每个线程使用的都是同一套。当线程被调度时,会将上下文数据加载到寄存器中;当发生线程切换时,会将上下文数据保存,以便下次被调度时可以将上下文数据重新加载到寄存器中
二、可重入与线程安全
2.1 概念
- 线程安全: 多个线程并发同一段代码时,不会出现不同的结果
- 重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则是不可重入函数。
注意: 线程安全讨论的是线程执行代码时是否安全,重入讨论的是函数被重入
2.2 常见线程不安全的情况
-
不保护共享变量的函数
-
函数状态随着被调用,状态发生变化的函数
-
返回指向静态变量指针的函数
-
调用线程不安全函数的函数
2.3 常见线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
2.4 常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O可以的很多实现都是以不可重入的方式使用全局数据结构
- 函数体内使用了静态的数据结构
2.5 常见可重入的情况
- 不使用全局变量或静态变量
- 不使用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都由函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
2.6 可重入与线程安全的关系
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的(可重入函数是线程安全函数的一种)
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 若一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
- 若对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的
三、死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态
单执行流产生死锁
单执行流也可能产生死锁,若某一执行流连续申请了两次锁,那么此时该执行流就会被挂起。因为该执行流第一次申请锁时是申请成功的,但第二次申请锁时因为该锁已经被申请过了,于是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有办法释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态
#include <iostream>
#include <pthread.h>
using namespace std;
void *Routine(void *pmtx)
{
pthread_mutex_lock((pthread_mutex_t*)pmtx);
pthread_mutex_lock((pthread_mutex_t*)pmtx);
pthread_mutex_unlock((pthread_mutex_t*)pmtx);//无法执行
pthread_exit(nullptr);
}
int main()
{
pthread_mutex_t mtx;
pthread_mutex_init(&mtx, nullptr);
pthread_t tid;
pthread_create(&tid, nullptr, Routine, (void *)&mtx);
pthread_join(tid, NULL);//等待不到
pthread_mutex_destroy(&mtx);
return 0;
}
此时主线程阻塞等待新线程退出,但是线程被阻塞进入死锁状态
该进程当前的状态是 sl+ ,其中 l 就是lock的意思,表示该进程当前处于一种死锁的状态
多执行流产生死锁
线程A申请锁资源的顺序为:锁1、锁2;线程B申请锁资源的顺序为:锁2、锁1
当线程A申请到锁1准备申请锁2时,线程B已申请到锁2准备申请锁1,这时两个线程都会因为申请锁失败而陷入阻塞,并且无法释放锁,进入死锁状态
产生死锁的条件
- 互斥条件: 一个资源每次只能被一个执行流使用
- 请求与保持条件: 一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件: 一个执行流已获得的资源,在未使用完之前,不能强行剥夺
- 循环等待条件: 若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
四、线程同步
4.1 同步概念与竞态条件
同步: 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫做同步
竞态条件: 因为时序问题,而导致程序异常,我们称之为竞态条件
- 单纯的加锁是会存在某些问题的,若某个线程的优先级较高或竞争力较强,每次都能够申请到锁,但申请到锁之后什么也不做,那么这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题
- 单纯的加锁是没有错的,它能够保证在同一时间只有一个线程进入临界区,但它没有高效的让每一个线程使用这份临界资源
- 现在增加一个规则,当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后
- 增加这个规则之后,下一个获取到锁的资源的线程就一定是在资源等待队列首部的线程,若有十个线程,就能够让这十个线程按照某种次序进行临界资源的访问
譬如,现在有两个线程访问一块临界资源,一个线程往临界资源写入数据,另一个线程从临界资源读取数据。但负责数据写入的线程的竞争力特别强,该线程每次都能竞争到锁,那么此时该线程就一直在执行写入操作,直到临界资源被写满,此后该线程就一直在进行申请锁和释放锁。而负责数据读取的线程由于竞争力太弱,每次都申请不到锁,因此无法进行数据的读取。引入同步后该问题就能很好的解决
4.2 条件变量
4.2.1 概念
条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述
条件变量主要包括两个动作:
- 一个线程等待条件变量的条件成立而被挂起
- 另一个线程使条件成立后唤醒等待的线程
条件变量通常需要配合互斥锁一起使用
4.2.2 接口
初始化条件变量
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;
销毁条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
参数cond:需要销毁的条件变量的地址
返回值:条件变量销毁成功返回0,失败返回错误码
注意:使用PTHREAD_COND_INITIALIZER初始化的条件变量不需要销毁
等待条件变量满足
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,失败返回错误码
4.2.3 为什么pthread_cond_wait需要互斥量
- 条件等待是线程间同步的一种手段。若只有一个线程,并且其条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作改变共享变量,使原先不满足的条件变得满足,并且通知等待在条件变量上的线程
- 条件不会无缘无故变得满足了,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据
- 在调用pthread_cond_wait函数时,将对应的互斥锁传入,当线程因为某些条件不满足需要在该条件变量下进行等待时,就会自动释放该互斥锁。让其他线程也可以得到锁,进入条件变量的等待队列,这样就不会发生同一个线程多次抢占锁的情况
- 当该线程被唤醒时,该线程会接着执行临界区内的代码,并且会自动获得对应的互斥锁
错误的设计
当进入临界区上锁后,若发现条件不满足,先解锁,然后在该条件变量下进行等待
//错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false){
pthread_mutex_unlock(&mutex);
//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
pthread_cond_wait(&cond, &mutex);
pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
不可行。解锁后、调用pthread_cond_wait()函数前,若此时有其他线程获取到互斥量,发现此时条件满足,于是发送了信号,那么此时pthread_cond_wait函数将错过这个信号(已经释放锁,无法在拿到锁了),最终可能会导致线程永远不会被唤醒。调用pthread_cond_wait()的线程必须是持有锁的
4.2.4 使用规范
等待条件变量的代码
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);