浅析条件变量
本篇博客会深入分析条件变量实现来理解两个问题:
- 为什么条件变量要配合锁来使用 — 信号丢失问题
- 为什么条件变量需要用while循环来判断条件,而不是if — 虚假唤醒(spurious wakeup)问题
这两个问题不仅是C会遇到,所有语言的封装几乎都不可避免
先区分一下条件和条件变量,条件是指常用情况下,signal线程修改的值,使得cond_wait判断该值后不再阻塞
条件变量为什么要锁这个问题很久以前就想过。
首先说一下结论,条件变量的条件不管用不用原子变量,正确写法分别如下
1 | // 经典写法,条件pass用锁 |
信号丢失
使用原子变量作为条件踩到的坑
简化下来是这样
1 | atomic<int> pass = {0}; |
想着我都用条件变量了,加什么锁,要不是pthread_cond_wait一定要我传入一个锁,我连这个锁都给它省了
这少写了两行代码血赚,这么想着就美滋滋的开始了测试
然后debug查了一晚上,挠秃了头,才定位到是标新立异的原子变量条件踩坑了
根据using-stdcondition-variable-with-atomicbool才让我恍然大悟
Why you should protect writes to shared variable even if it is atomic:
There could be problems if write to shared variable happens between checking it in predicate and waiting on condition. Consider following:
- Waiting thread wakes spuriously, aquires mutex, checks predicate and evaluates it to
false, so it must wait on cv again.- Controlling thread sets shared variable to
true.- Controlling thread sends notification, which is not received by anybody, because there is no threads waiting on conditional variable.
- Waiting thread waits on conditional variable. Since notification was already sent, it would wait until next spurious wakeup, or next time when controlling thread sends notification. Potentially waiting indefinetly.
就是这样的case
| 时序 | 线程A | 线程B |
|---|---|---|
| 1 | pthread_mutex_lock(mtx); | |
| 2 | while(pass == 0) { | |
| 3 | pass = 1; | |
| 4 | pthread_cond_signal(cv); | |
| 5 | pthread_cond_wait(cv, mtx); | |
| 6 | } |
时序2 :线程A在时序2判断pass == 0(首次进入或者stackoverflow提到的虚假唤醒,在这个case下没有区别)
时序3:线程B设置了pass == 1,没什么用,线程A已经判断过了,进了wait直接阻塞,不会再判断了
时序4:线程B发出了signal,没什么用,因为线程A还没开始wait
时序5:线程A陷入wait,如果没有下一次signal,线程A永远不会再被唤醒
所以条件变量的锁是用来保证条件while(pass==0)和pthread_cond_wait的原子性,保证这中间不会修改条件
那问题又来了,这个锁传入条件变量,肯定是由条件变量来解锁的,否则一直持有着锁,其他线程根本没法唤醒他
在条件变量解锁和进入内核态休眠以前,是怎么保证这两个行为的原子性的呢
条件变量解锁的原子性保证
以下是条件变量实现源码
1 | int __pthread_cond_signal (pthread_cond_t *cond) { |
1 | int __pthread_cond_wait (pthread_cond_t *cond; |
再列一下时序:
| 时序 | 线程A(pthread_cond_wait) | 线程B(pthread_cond_signal) |
|---|---|---|
| 1 | unsigned int futex_val = cond->__data.__futex; | |
| 2 | lll_unlock (cond->__data.__lock, pshared); | lll_lock (cond->__data.__lock, pshared); |
| 3 | ++cond->__data.__futex; | |
| 4 | lll_futex_wake (&cond->__data.__futex, 1, pshared); | |
| 5 | lll_unlock (cond->__data.__lock, pshared); | |
| 6 | lll_futex_wait (&cond->__data.__futex, futex_val, pshared); |
时序1:lll_futex_wait 在解锁前记录下了futex的值futex_val
时序3:线程B的pthread_cond_signal会改变futex的值,当进入lll_futex_wait futex后,发现futex值和futex_val不同。
会使得休眠失败,直接执行下面的语句,因此只要进入了cond_wait函数后,即使还没执行futex_wait时就解锁了,依然可以唤醒这个准备休眠的线程
pthread_cond_signal的加锁
情况1:线程A(pthread_cond_wait)先持有锁
一旦线程A先进行pthread_cond_wait,线程B后执行的pthread_cond_signal一定能唤醒线程A,在这种情况下pthread_cond_signal加不加锁都不重要
情况2:线程B(pthread_cond_signal)先持有锁
| 时序 | 线程A(pthread_cond_wait) | 线程B(pthread_cond_signal) |
|---|---|---|
| 1 | pthread_mutex_lock(mtx); | |
| 2 | pass = 1; | |
| 3 | pthread_mutex_unlock(mtx); | |
| 4 | pthread_mutex_lock(mtx); | |
| 5 | while(pass == 0) { ===> pass已经是1了,所以跳出循环 | |
| 6 | pthread_mutex_unlock(mtx); |
可以看到线程A根本不会进入睡眠,这种情况下,有没有pthread_cond_signal都不重要了,何况是他要不要加锁呢
综上pthread_cond_signal可加可不加,看心情,也不会有什么性能损耗
对C++来说,由于RAII用起来方便,十有八九是加锁了,如下
1 | std::unique_lock<std::mutex> lock(mtx); |
虚假唤醒
虚假唤醒有两种
这两种情况决定了条件变量返回后需要用while循环来判断条件,如果是虚假唤醒那么继续wait
一次signal唤醒2个或以上线程
第一种情况在man pthread_cond_signal中描述的很清楚,点这里看网页版本
其中的Multiple Awakenings by Condition Signal部分举了一个例子,当A线程即将wait,B线程已经wait,此时来了一个signal,由于第二节提到的futex值发生了变化,不仅B从cond_wait中返回,A也是一样。因此唤醒了两个线程
没有发生signal但是唤醒了线程
futex这样的长等待系统调用会被信号打断,对其他系统调用来说,被打断会有一个EINTR错误,可以直接重试,并不影响本身的语义,例如read失败,或者互斥锁。但是对条件变量来说,可能就会错过已经发生的signal,因此一旦被打断就会直接返回。
这个解释是在为什么条件锁会产生虚假唤醒现象(spurious wakeup)?看到的
参考资料
[并发系列-5] 从AQS到futex(三): glibc(NPTL)的mutex/cond实现
-
2023-08-05
之前在理解条件变量时在futex上误入歧途,导致犯下死锁的错误,并且写下了错误的博文,见浅析条件变量
所以打算重新理解一下futex
虽然初衷是futex,但是由于futex最初是为了优化锁的性能而提出,因此深入理解futex实际上是对锁概念的深入了理解
因此本篇的大纲
源码分析从c++的mutex开始,然后是glibc的nptl库pthread_mutex,接着是linux内核的futex实现
-
2024-12-02
本篇总结一下信号的必要知识,以及实际场景下的处理,主要参考UNIX环境高级编程(第三版)
要先从子进程fork开始总结,因为信号和子进程息息相关
wait系统调用
根据书中8.3节 fork以及8.6节 wait
- fork出子进程以后,假如子进程退出,需要用wait或者waitpid检查子进程的退出状态,否则子进程会进入僵死状态,直到父进程退出,被系统的1号进程接管进行wait才会释放。
- 父进程可以直接wait等待,也可以啥也不做,在SIGCHLD信号处理函数中,再进行wait调用
- waitpid比wait多一些功能,最主要的就是可以在第二个参数传入WNOHANG进行非阻塞调用
-
2023-08-05
上一篇对glibc和futex进行了源码分析,是我对具体实现的梳理,没有总结性的内容,可以略过不看直接看这一篇总结
本篇总结尝试深入一些,继续挖掘锁这个概念
分析内核在实现锁的过程中是如何解决无效唤醒问题的
为了解决无效唤醒问题,纯用户态的互斥锁性能不够好;纯内核态又在非竞争的条件时需要陷入内核,从而诞生了futex