Linux 线程互斥
目录
Linux线程互斥
进程线程间的互斥相关背景概念
互斥量的接口
初始化互斥量
互斥量加锁和解锁
锁的封装
编辑
互斥量加锁的非阻塞版本
互斥量实现原理探究
可重入VS线程安全
概念
常见的线程不安全的情况
常见的线程安全的情况
常见的不可重入的情况
常见的可重入的情况
可重入与线程安全联系
可重入与线程安全区别
常见锁概念
死锁
死锁的四个必要条件
避免死锁
避免死锁算法
Linux线程同步
条件变量
条件变量函数
条件变量使用规范
Linux线程互斥
进程线程间的互斥相关背景概念
- 临界资源: 多线程执行流共享的资源叫做临界资源。
- 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区。
- 互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
- 原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
对一个全局变量进行多线程并发 -- / ++ 操作是否是安全的?
我们来试一下模拟多线程进行抢票的场景看会出现什么问题:
#include <iostream>
#include <vector>
#include <cstdio>
#include <string>
#include <unistd.h>#define NUM 5
using namespace std;int tickets = 1000;void *ThreadRoutine(void *args)
{int i = (uint64_t)args;while(true){ if(tickets > 0){usleep(1000);printf("who=thread->%d get a ticket: %d\n", i, tickets);--tickets;}else{break;}}pthread_exit((void*)666);
}int main()
{pthread_t tid;vector<pthread_t> tids;for(int i = 0; i < NUM; i++){pthread_create(&tid, nullptr, ThreadRoutine, (void*)i);tids.push_back(tid);}for(int i = 0; i < NUM; i++){pthread_join(tids[i], nullptr);}return 0;
}
运行结果显然不符合我们的预期,因为其中出现了剩余票数为负数的情况。
为什么无法获得预期结果?
- if 语句判断条件为真以后,代码可以并发的切换到其他线程
- usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
- --ticket 操作本身就不是一个原子操作
为什么--ticket操作不是一个原子操作?
-- 操作并不是原子操作,而是对应三条汇编指令:
- load :将共享变量ticket从内存加载到寄存器中
- update : 更新寄存器里面的值,执行-1操作
- store :将新值,从寄存器写回共享变量ticket的内存地址
我们取出--tickets的汇编代码
movl tickets(%rip), %eax
subl $1, %eax
movl %eax, tickets(%rip)
既然--操作需要三个步骤才能完成,那么就有可能当thread1刚把tickets的值读进CPU就被切走了,也就是从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看起来就是一行代码,但这行代码被编译器编译后本质上是三行汇编,相反,对一个变量进行++也需要对应的三个步骤,即++操作也不是原子操作。
tips:
寄存器不等于寄存器上下文,不同线程寄存器内容不一样
回到卖票场景: 这就是共享资源在被线程在并发访问时而导致的数据不一致问题
要解决上述抢票系统的问题,需要做到三点:
- 代码必须有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
互斥量的接口
初始化互斥量
初始化互斥量有两种方法:
方法一:动态分配
使用pthread_mutex_init对互斥量进行初始化
函数原型如下:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数说明:
- mutex:需要初始化的互斥量。
- attr:初始化互斥量的属性,一般设置为NULL即可。
返回值说明:
- 互斥量初始化成功返回0,失败返回错误码。
方法二:静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
销毁互斥量
销毁互斥量的函数叫做pthread_mutex_destroy,该函数的函数原型如下:
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数说明:
- mutex:需要销毁的互斥量。
返回值说明:
- 互斥量销毁成功返回0,失败返回错误码。
销毁互斥量需要注意:
- 使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁。
- 不要销毁一个已经加锁的互斥量。
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误码
调用 pthread_mutex_lock 时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量, 那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
改进上面的售票系统
例如,我们在上述的抢票系统中引入互斥量,每一个线程要进入临界区之前都必须先申请锁,只有申请到锁的线程才可以进入临界区对临界资源进行访问,并且当线程出临界区的时候需要开锁,这样才能让其余要进入临界区的线程继续竞争锁。
#include <iostream>
#include <vector>
#include <cstdio>
#include <string>
#include <unistd.h>#define NUM 5
using namespace std;int tickets = 1000;
pthread_mutex_t mutex;
//pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; 这样写就不用对互斥量进行初始化和销毁了void *ThreadRoutine(void *args)
{int i = (uint64_t)args;while(true){ pthread_mutex_lock(&mutex);if(tickets > 0){usleep(1000);printf("who=thread->%d get a ticket: %d\n", i, tickets);--tickets; }else{break;}pthread_mutex_unlock(&mutex);}pthread_exit((void*)666);
}int main()
{pthread_mutex_init(&mutex);pthread_t tid;vector<pthread_t> tids;for(int i = 0; i < NUM; i++){pthread_create(&tid, nullptr, ThreadRoutine, (void*)i);tids.push_back(tid);}for(int i = 0; i < NUM; i++){pthread_join(tids[i], nullptr);}pthread_mutex_destroy(&mutex);return 0;
}
运行代码,此时在抢票过程中就不会出现票数剩余为负数的情况了。
但是我们可以注意到抢票的过程全都是由线程3完成的
问题1:票被一个线程抢完
解决方法:让该线程进入短暂休眠
运行结果:
虽然解决了锁竞争问题,但是它并没有按我们的代码逻辑运行,当票数剩余1时所有线程应该会结束运行
问题2:线程执行完任务之后为什么还是处于阻塞状态
解决方法:
注意:
- 在大部分情况下,加锁本身都是有损于性能的事,但是为了保证在多执行流下的线程安全,我们不得不使用锁,所以加锁的本质就是使用时间换取安全,加锁的表现就是线程对于临界区的资源串行访问。
- 我们应该在合适的位置进行加锁和解锁,这样能尽可能减少加锁带来的性能开销成本,临界区代码越少越好。
- 进行临界资源的保护,是所有执行流都应该遵守的标准,这时程序员在编码时需要注意的。
锁的封装
#ifndef __LOCKGUARD_HPP__
#define __LOCKGUARD_HPP__#include <iostream>
#include <pthread.h>using namespace std;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
{
public:LockGuard(pthread_mutex_t *lock):_mutex(lock){_mutex.lock();}~LockGuard(){_mutex.unlock();}
private:Mutex _mutex;
};#endif
#include <iostream>
#include <vector>
#include <cstdio>
#include <string>
#include <unistd.h>
#include "LockGuard.hpp"#define NUM 5
using namespace std;int tickets = 1000;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; void *ThreadRoutine(void *args)
{int i = (uint64_t)args;while(true){ {LockGuard mutex(&lock);if(tickets > 0){usleep(1000);printf("who=thread->%d get a ticket: %d\n", i, tickets);--tickets; }else{break;}}}pthread_exit((void*)666);
}int main()
{pthread_t tid;vector<pthread_t> tids;for(int i = 0; i < NUM; i++){pthread_create(&tid, nullptr, ThreadRoutine, (void*)i);tids.push_back(tid);}for(int i = 0; i < NUM; i++){pthread_join(tids[i], nullptr);}return 0;
}
运行结果:
互斥量加锁的非阻塞版本
int pthread_mutex_trylock(pthread_mutex_t *mutex);
功能:如果申请锁成功,则返回0,否则返回不为0的错误码,与pthread_mutex_lock相比,他在申请锁失败时,不会进行阻塞等待
互斥量实现原理探究
加锁后的原子性体现在哪里?
每一个线程在进入临界区访问临界区资源时都需要先申请同一份锁资源,在其他线程看来申请到锁的线程只有两种状态,要么持有锁,要么释放锁,因为只有这两种状态对其他线程才是有意义的。
对于thread-1 thread-2 thread-3来说,它们只关心thread-1是否持有锁或者释放锁,因为只有thread-1的这两种状态才对其他线程有意义
此时,对于thread-1 thread-2 thread-3来说thread-1的操作过程就是原子性的
临界区内的线程可能进行线程切换吗?
会的,在线程任何地方都会被切换
但是线程是被持有锁切出去的,所以在该线程不在期间,其他线程是没有权限访问临界区的
锁是否需要被保护?
我们说被多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。
既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?
锁实际上是自己保护自己的,申请锁和释放锁本身就是原子性的,那么锁自然就是安全的。
如何保证申请锁的过程是原子的?
- 上面我们已经说明了--和++操作不是原子操作,可能会导致数据不一致问题。
- 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相交换。
- 由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。
上面我们已经说明了--和++操作不是原子操作,可能会导致数据不一致问题。
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相交换。
由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。
操作系统的工作原理:
• 操作系统一旦启动成功后就是一个死循环。
• 时钟是计算机中的一个硬件,时钟每隔一段时间会向操作系统发起一个时钟中断,操作系统就会根据时钟中断去执行中断向量表。
• 中断向量表本质上就是一个函数表,比如刷磁盘的函数、检测网卡的函数以及刷新数据的函数等等。
• 计算机不断向操作系统发起时钟中断,操作系统就根据时钟中断,不断地去执行对应的代码。
• CPU有多个,但总线只有一套。CPU和内存都是计算机中的硬件,这两个硬件之间要进行数据交互一定是用线连接起来的,其中我们把CPU和内存连接的线叫做系统总线,把内存和外设连接起来的线叫做IO总线。
• 系统总线只有一套,有的时候CPU访问内存是想从内存中读取指令,有的时候是想从内存读取数据,所以总线是被不同的操作种类共享的。计算机是通过总线周期来区分此时总线当中传输的是哪种资源的。
下面我们来看看lock和unlock的伪代码:
lock:movb $0, % alxchgb %al, mutexif (al 寄存器的内容> 0){return 0;} else挂起等待;goto lock;
unlock:movb $1, mutex唤醒等待 Mutex 的线程;return 0;
我们可以认为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内的寄存器不是被所有的线程共享的,每个线程都有自己的一组寄存器,但内存中的数据是各个线程共享的。申请锁实际就是,把内存中的mutex通过交换指令,原子性的交换到自己的al寄存器中。
可重入VS线程安全
概念
线程安全: 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程安全问题。
重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则是不可重入函数。
注意: 线程安全讨论的是线程执行代码时是否安全,重入讨论的是函数被重入进入。
常见的线程不安全的情况
1. 不保护共享变量的函数。
2. 函数状态随着被调用,状态发生变化的函数。
3. 返回指向静态变量指针的函数。
4. 调用线程不安全函数的函数。
常见的线程安全的情况
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
类或者接口对于线程来说都是原子操作。
多个线程之间的切换不会导致该接口的执行结果存在二义性。
常见的不可重入的情况
调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
调用了标准I/O库函数,标准I/O可以的很多实现都是以不可重入的方式使用全局数据结构。
可重入函数体内使用了静态的数据结构。
常见的可重入的情况
不使用全局变量或静态变量。
不使用malloc或者new开辟出的空间。
不调用不可重入函数。
不返回静态或全局数据,所有数据都由函数的调用者提供。
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
可重入与线程安全联系
函数是可重入的,那就是线程安全的。
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别
可重入函数是线程安全函数的一种。
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。
理解四个概念即可
线程安全:多执行流执行时产生的并发问题
可重入:函数特点问题
不可重入函数:在被多个执行流重入的情况下可能会遇到线程安全问题
可重入函数:在被多个执行流重入的情况下不会遇到线程安全问题
常见锁概念
死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
单执行流可能产生死锁吗?
如果某一执行流连续申请了两次锁,那么此时该执行流就会被挂起。因为该执行流第一次申请锁成功,但第二次申请锁时因为该锁已经被申请过了,于是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态。
例如,在下面的代码中我们让主线程创建的新线程连续申请了两次锁
#include <iostream>
#include <cstdio>
#include <pthread.h>using namespace std;pthread_mutex_t lock;void *threadRoutine(void *args)
{pthread_mutex_lock(&lock);pthread_mutex_lock(&lock);pthread_exit((void*)6666);
}int main()
{pthread_mutex_init(&lock, nullptr);pthread_t tid;pthread_create(&tid, nullptr, threadRoutine, nullptr); pthread_join(tid, nullptr);pthread_mutex_destroy(&lock);return 0;
}
用ps
命令查看该进程时可以看到,该进程当前的状态是Sl+
,其中的l
实际上就是lock的意思,表示该进程当前处于一种死锁的状态,而右边的程序处于挂起状态。
什么叫做阻塞?
进程运行时是被CPU调度的,换句话说进程在调度时是需要用到CPU资源的,每个CPU都有一个运行等待队列(runqueue),CPU在运行时就是从该队列中获取进程进行调度的,同时伴随一个等待队列,处于等待队列或没被cpu执行的进程,这种状态我们就做阻塞状态。
在运行等待队列中的进程本质上就是在等待CPU资源,实际上不止是等待CPU资源如此,等待其他资源也是如此,比如锁的资源、磁盘的资源、网卡的资源等等,它们都有各自对应的资源等待队列。
例如,当某一个进程在被CPU调度时,该进程需要用到锁的资源,但是此时锁的资源正在被其他进程使用:
那么此时该进程的状态就会由R状态变为某种阻塞状态,比如S状态。并且该进程会被移出运行等待队列,被链接到等待锁的资源的资源等待队列,而CPU则继续调度运行等待队列中的下一个进程。
此后若还有进程需要用到这一个锁的资源,那么这些进程也都会被移出运行等待队列,依次链接到这个锁的资源等待队列当中。
直到使用锁的进程已经使用完毕,也就是锁的资源已经就绪,此时就会从锁的资源等待队列中唤醒一个进程,将该进程的状态由S状态改为R状态,并将其重新链接到运行等待队列,等到CPU再次调度该进程时,该进程就可以使用到锁的资源了。
总结一下:
- 站在操作系统的角度,进程等待某种资源,就是将当前进程的task_struct放入对应的资源等待队列,这种情况可以称之为当前进程被挂起等待了。
- 站在用户角度,当进程等待某种资源时,用户看到的就是自己的进程卡住不动了,我们一般称之为应用阻塞了。
这里所说的资源可以是硬件资源也可以是软件资源,锁本质就是一种软件资源,当我们申请锁时,锁当前可能并没有就绪,可能正在被其他线程所占用,此时当其他线程再来申请锁时,就会被放到这个锁的资源等待队列当中。
死锁的四个必要条件
- 互斥条件(前提): 一个资源每次只能被一个执行流使用。
- 请求与保持条件(原则): 一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件(原则): 一个执行流已获得的资源,在未使用完之前,不能强行剥夺。
- 循环等待条件: 若干执行流之间形成一种头尾相接的循环等待资源的关系。
注意: 这是死锁的四个必要条件,只要产生死锁就一定满足了这四个条件。
避免死锁
- 破坏死锁的四个必要条件。
1. 不使用锁 2. 释放掉其中一个人的锁 3. 按顺序申请锁资源
- 加锁顺序一致。
- 避免锁未释放的场景。
- 资源一次性分配。
避免死锁算法
死锁检测算法
银行家算法
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 <cstdio>
#include <pthread.h>
#include <unistd.h>using namespace std;
#define NUM 5int cnt = 0;pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;void *threadRoutine(void *args)
{pthread_detach(pthread_self());uint64_t i = (uint64_t)args;while(true){sleep(1);pthread_mutex_lock(&lock);pthread_cond_wait(&cond, &lock); //申请锁的同时会释放锁cout << "thread-> " << i << " cnt: " << cnt++ << endl; //显示器文件, cnt都属于临界资源pthread_mutex_unlock(&lock);usleep(1000);}pthread_exit((void*)666);
}int main()
{pthread_t tid;for(int i = 0; i < NUM; i++){pthread_create(&tid, nullptr, threadRoutine, (void*)i);}//主线程释放其他线程就会自动释放while(true){pthread_cond_signal(&cond); //唤醒该队列下首个线程//主线程唤醒在锁资源等待队列的线程sleep(1);}return 0;
}
如果我们想每次唤醒都将在该条件变量下等待的所有线程进行唤醒,可以将代码中的pthread_cond_signal
函数改为pthread_cond_broadcast
函数。
tips: C++源文件的后缀可以是.cpp
、.cc
、.cxx
。
为什么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);