浅析条件变量
本篇博客会深入分析条件变量实现来理解两个问题:
- 为什么条件变量要配合锁来使用 — 信号丢失问题
- 为什么条件变量需要用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实现
-
2023-08-05
上一篇对glibc和futex进行了源码分析,是我对具体实现的梳理,没有总结性的内容,可以略过不看直接看这一篇总结
本篇总结尝试深入一些,继续挖掘锁这个概念
分析内核在实现锁的过程中是如何解决无效唤醒问题的
为了解决无效唤醒问题,纯用户态的互斥锁性能不够好;纯内核态又在非竞争的条件时需要陷入内核,从而诞生了futex