条件变量(Condition Variable) Notify的底层机制
工作中又一次遇到了一个非常棘手但是有意思的问题,我们来看以下的代码
1 | // Some simple cv implementation |
对熟悉condition variable(以下简称cv)的朋友来说,MyClass的实现是再简单不过的(实际情况下当然Notify中间会改动一些internal state,但总体实现就是这么简单)。那对于这么简单的一个cv实现,如果我给定如下条件:
Wait()
会被多个线程所调用,Notify()
只会被某一个线程调用- 整体CPU使用率偏高
请问当Notify()
被调用时,Wait()
可能会被卡住吗?答案当然是可能的,因为CPU的resource是有限的,而背景CPU使用率偏高,所以Wait()
会自然可能因为拿不到资源而卡住。
那接下来的问题是:Notify()
会卡住吗?
我原本会理所当然地认为:当然不会!不就是发个signal吗,Wait()
此时是yield CPU resources的,不会有任何系统操作。哪怕Notify()
本身需要某些os level的锁,只要不被并行的调用不也没问题吗?而我们给定的情况就是:Notify()
只会被一个线程所调用,所以也不可能会被os mutex所卡住。
而答案是惊人的:在某些特定条件下,Notify()
会卡住,而且卡住的时间竟然会和某些Wait()
卡住的时间一致。接下来我们通过阅读Glibc的NPTL源码来一探究竟。
当然TL;DR: 在使用cv时,如果有多个thread在调用wait()
,那么notify_one()
会因为某些wait()
无法被schedule上而卡住。卡住的时间为wait()
的schedule delay。
Glibc中的condition variable实现
首先,Glibc的NPTL实现非常复杂,这篇文章不想深入讨论诸如cv的MO实现、具体的wait/cancel,只想通过“为什么Notify()会卡住”这一个工作中实际遇到的问题作为契机,来理解这个问题本身。由此,整段源码分析不会纠结于细节,我们只关注一件事:何种情况下notify_one()会卡住。
以下几章中阅读的Glibc NPTL源码版本为glibc-2.35。
一读pthread_cond_signal()
在Linux下,notify_one()
的实现本质是pthread_cond_signal()
,为了探索为什么notify_one()
会卡住,我们需要了解pthread_cond_signal()
的实现。
Again,文章只关注为什么notify_one()
会卡住,所以不会深入讨论pthread_cond_signal()
的实现细节。
因此,我们只需要关住pthread_cond_signal()
中哪里可能会被卡住。而阅读源码会发现代码中共有两处调用了futex_wait_simple()
。阅读futex_wait_simple()
的官方文档可以发现,这个操作不是自旋的,而是会sleep。
第一处的futex_wait_simple()
来自__condvar_acquire_lock()
,这个很好理解,因为本质上就是获取一个os level的锁,便于对一个cv的内部结构进行操作。而第二个futex_wait_simple()
则更加耐人寻味,它隐藏在这个名叫__condvar_quiesce_and_switch_g1()
的函数中。
整个pthread_cond_signal()
的实现非常简洁,我们会在之后的section中详细讨论。目前需要注意的有两点
- 如果
cond->__data.__g_size[g1] == 0
,则__condvar_quiesce_and_switch_g1()
会被调用,而该函数中会调用futex_wait_simple()
- 当且仅当
cond->__data.__g_size[g1] != 0
,或者__condvar_quiesce_and_switch_g1()
被调用后返回true,则futex_wake()
会被调用。
1 | int |
那接下来我们就需要看看,第一:在这个cv的结构里,__g_size
是什么?第二:__condvar_quiesce_and_switch_g1()
为什么在此时调用,它要做什么?
cv的结构
cv的结构相当简单,注释在pthread_cond_wait()
的实现中十分清晰。需要特别关注的只有一点,我们会发现其中重要的数据结构如:__g_size
,__g_signals
,__g_refs
都是一个大小为2数组。这里的2分别代表是什么呢?
再去研读pthread_cond_wait()
的实现就会发现,整个cv的实现分成了两个组g1和g2,所有的pthread_cond_wait()
都会先被分到g2,而pthread_cond_signal()
永远只会给g1的waiters发送信号唤醒。而当g1中没有waiters的时候,pthread_cond_signal()
会调用__condvar_quiesce_and_switch_g1()
,将g1和g2互换。
这样的实现有一个最大的好处:简单。当pthread_cond_wait()
被调用时,它只需要将waiter扔到g2,然后等待notify对它进行操作。而当pthread_cond_signal()
被调用时,它只需要处理g1,并行的数据结构不至于很多,实现起来方便得多。
可是Concurrency问题会在g1为空,但是g2不为空的时候到来:我们需要安全的处理g2和g1的切换。自然我们知道要加锁,可是下一个问题是,假设切换的时候g1中存在一些waiters,它们虽然已经被signal了,但是因为各种原因没有被唤醒,我们要怎么办?
直接冒在脑子里的最简单的做法是:强行直接交换。反正G1中的waiter已经被signal过了,能不能被唤醒取决于操作系统,我不需要再担心。
要回答这个问题,我们需要来仔细看看cond_var中的结构
1 | struct __pthread_cond_s |
在这个实现中,__g_signals
存储的是一个真正的futex word,而与之共用的是__g_refs
中存储了所有目前还在引用这个futex word的线程。如果强行交换,会导致可能存在大量的线程持有这一futex word,唤醒过后都会大量改动 / 使用其中的数据,最终导致可能的的ABA问题,足以想象其中实现的困难。
而glic最后的实现方式就是,如果g1存在一些waiters并没有被唤醒,则需要notify等候直到它被唤醒。接下来我们重新进入pthread_cond_notify()
,来理解其中具体的实现。
重读pthread_cond_notify()
这一次我们从第一个condition开始,然后深入__condvar_quiesce_and_switch_g1()
的实现,看看我们的理解是否正确。最重要的if
条件如下
1 | if ((cond->__data.__g_size[g1] != 0) |
第一个condition __g_size[g1] != 0
如我们上面所想,即g1内已经没有了正在等待的waiter。因为如果g1存在waiters,那么notify要做的事情非常简单,直接send信号唤醒即可。而如果g1里已经没有了任何waiters,接下来要做的就是交换g1 / g2,也就是__condvar_quiesce_and_switch_g1()
所做的事情。
我们直接来读其中调用futex_wait_simple()
的段落,来看具体到底是什么条件会使得notify的thread被Block住。
1 | // Fetch __g_refs[g1] |
本质上就是判断__g_refs[g1]
是否大于0 ———— 即有没有一个thread是被signal过,却没有唤醒的(之所以r >> 1
是因为__g_refs
的第一位是另有作用的,从第二位开始表达是否有thread仍在引用futex)。
合二为一,总结而言就是在以下条件满足时,notify_one()
有可能会跑的很慢
- 有一个waiting thread已经被signal过了,但由于各种原因(如CPU资源不够)没有被唤醒
- 有一个waiting thread是在上一个signal后开始wait(g2)
实验证明
由此我们就可以用实验证明我们的理论,这个程序也极其容易写(见附录),只要在强行使用taskset -c 11 chrt -f 25 stress-ng -c 11 -l 100
卡死一个核,接着把waiting thread绑定在这个核上,这可以手动构造出signal但是没有醒的情况。在第二个notify调用之前,再开一个新的waiting thread(使其加入g2),最终就可以得到实验结果如下
Takeaways
到这里,我们就可以得出一些使用cv的principle
- waiting thread需要有比较高的priority,使得它们可以被及时schedule上
- 一对一的cv使用wait - notify会是最好的方式,如果有多个thread,可以尝试使用多个cv
Appendix
1 | class Test { |