浅析条件变量

本篇博客会深入分析条件变量实现来理解两个问题:

  • 为什么条件变量要配合锁来使用 — 信号丢失问题
  • 为什么条件变量需要用while循环来判断条件,而不是if — 虚假唤醒(spurious wakeup)问题

这两个问题不仅是C会遇到,所有语言的封装几乎都不可避免


先区分一下条件和条件变量,条件是指常用情况下,signal线程修改的值,使得cond_wait判断该值后不再阻塞

条件变量为什么要锁这个问题很久以前就想过。

首先说一下结论,条件变量的条件不管用不用原子变量,正确写法分别如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 经典写法,条件pass用锁
// 线程A,条件测试
pthread_mutex_lock(mtx);
while(pass == 0) {
pthread_cond_wait(cv, mtx);
}
pthread_mutex_unlock(mtx);

// 线程B,条件发生修改,对应的signal代码
pthread_mutex_lock(mtx);
pass = 1;
pthread_cond_signal(cv);
pthread_mutex_unlock(mtx);
或者signal不用加锁
pthread_mutex_lock(mtx);
pass = 1;
pthread_mutex_unlock(mtx);
pthread_cond_signal(cv);

信号丢失

使用原子变量作为条件踩到的坑

简化下来是这样

1
2
3
4
5
6
7
8
9
10
11
12
atomic<int> pass = {0};

// 线程A,条件测试
pthread_mutex_lock(mtx);
while(pass == 0) {
pthread_cond_wait(cv, mtx);
}
pthread_mutex_unlock(mtx);

// 线程B,条件发生修改,对应的signal代码
pass = 1;
pthread_cond_signal(cv);

想着我都用条件变量了,加什么锁,要不是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:

  1. Waiting thread wakes spuriously, aquires mutex, checks predicate and evaluates it to false, so it must wait on cv again.
  2. Controlling thread sets shared variable to true.
  3. Controlling thread sends notification, which is not received by anybody, because there is no threads waiting on conditional variable.
  4. 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的原子性,保证这中间不会修改条件

那问题又来了,这个锁传入条件变量,肯定是由条件变量来解锁的,否则一直持有着锁,其他线程根本没法唤醒他

在条件变量解锁和进入内核态休眠以前,是怎么保证这两个行为的原子性的呢

条件变量解锁的原子性保证

以下是条件变量实现源码

pthread_cond_signal

pthread_cond_wait

1
2
3
4
5
6
int __pthread_cond_signal (pthread_cond_t *cond) {
lll_lock (cond->__data.__lock, pshared);
++cond->__data.__futex;
lll_futex_wake (&cond->__data.__futex, 1, pshared);
lll_unlock (cond->__data.__lock, pshared);
}
1
2
3
4
5
6
7
8
9
int __pthread_cond_wait (pthread_cond_t *cond;
pthread_mutex_t *mutex) {
do {
unsigned int futex_val = cond->__data.__futex;
lll_unlock (cond->__data.__lock, pshared);
lll_futex_wait (&cond->__data.__futex, futex_val, pshared);
} while(val == seq || cond->__data.__woken_seq == val);
return __pthread_mutex_cond_lock (mutex);
}

再列一下时序:

时序 线程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
2
3
std::unique_lock<std::mutex> lock(mtx);
pass = 1;
cv.notify_one();

虚假唤醒

虚假唤醒有两种

这两种情况决定了条件变量返回后需要用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)?看到的

参考资料

条件变量 condition

87-深入条件变量

[并发系列-5] 从AQS到futex(三): glibc(NPTL)的mutex/cond实现

pthread_cond_wait 为什么需要传递 mutex 参数?

用条件变量实现事件等待器的正确与错误做法