| 本篇位置 | 锁管"互斥",条件变量管"等待"。两者解决的是不同的问题 |
| 运行环境 | 容器里的 Linux,gcc。演示限定一个核:--cpuset-cpus=0 |
一、锁解决不了的那个问题
第 17 篇的锁回答的是:“别人正在用,我不能进去。”
但还有另一种等待,锁完全帮不上忙:“东西还没准备好,我得等。”
典型例子是生产者-消费者:一个线程往队列里放,一个线程从队列里取。队列空的时候,消费者要等。
你可以用锁凑合:拿到锁,看看队列空不空,空就放开锁、再拿、再看……
while (cnt == 0) {
pthread_mutex_unlock(&m);
pthread_mutex_lock(&m); /* 放开再拿,就为了让生产者有机会进来 */
}
这叫忙等(busy waiting)。它是对的,但是——
打个比方
你在银行等叫号。
忙等是:你站在窗口前,每隔半秒问一次"好了吗?好了吗?好了吗?"
条件变量是:你去旁边坐着,叫到号了柜员喊你。
⭐ 区别不只是礼貌。你站在窗口前的时候,柜员没法办事——你占着那个位置。这句话在单核上是字面意义的真实:你在自旋,那个能让条件成立的线程根本拿不到 CPU。
二、跑一遍看看:四百四十倍
生产者往一个容量 8 的缓冲区放,消费者取,共十万件。只有一个核:
忙等 搬运 100000 件 墙上时间 75.00 秒 烧掉 CPU 75.00 秒
条件变量 搬运 100000 件 墙上时间 0.17 秒 烧掉 CPU 0.17 秒
75 秒 对 0.17 秒。四百四十倍。
而且看那两列——忙等的墙上时间和 CPU 时间一样,说明它一秒都没歇着,全在空转。
⚠️ 这个演示必须限定在一个核上才这么惨。多核上忙等没这么糟(生产者能在另一个核上跑),但依然在白烧一个核,而且还在抢缓存行(第 17 篇讲过)。
⭐ 这正是第 17 篇那个"自旋锁比互斥量慢九倍"的放大版。同一件事:拿不到就该睡,别站着。
三、条件变量:三个动作
条件变量只有三个操作,但每一个都有讲究:
pthread_cond_wait(&cond, &mutex); /* 我等 */
pthread_cond_signal(&cond); /* 叫醒一个 */
pthread_cond_broadcast(&cond); /* 全叫醒 */
wait 这一个调用做了三件事,而且是原子地做的:
- 放开你手里的锁。
- 把自己挂起(进入第 2 篇那个阻塞态)。
- 被叫醒之后,重新拿到那把锁,才返回。
⭐ 第 1 步和第 2 步必须原子,这是整件事的关键。
假设它们不原子——你先放开锁,然后准备睡。就在这个缝里,生产者拿到锁、放了东西、发了信号。而你还没睡着,这个信号你收不到。 然后你睡了,再也没人叫你。
这叫错失唤醒(lost wakeup),程序永远挂住。条件变量的 API 之所以要求"必须先持有锁再调 wait",就是为了让这两步能原子完成。
完整的生产者-消费者
void *producer(void *a) {
for (long i = 0; i < ITEMS; i++) {
pthread_mutex_lock(&m);
while (cnt == CAP) /* 满了就等 */
pthread_cond_wait(¬_full, &m);
buf[put] = i; put = (put + 1) % CAP; cnt++;
pthread_cond_signal(¬_empty); /* 告诉消费者:有货了 */
pthread_mutex_unlock(&m);
}
}
void *consumer(void *a) {
for (long i = 0; i < ITEMS; i++) {
pthread_mutex_lock(&m);
while (cnt == 0) /* 空了就等 */
pthread_cond_wait(¬_empty, &m);
get = (get + 1) % CAP; cnt--;
pthread_cond_signal(¬_full); /* 告诉生产者:有空位了 */
pthread_mutex_unlock(&m);
}
}
注意用了两个条件变量。第五节说为什么。
四、⭐ 三条规矩
规矩一:永远用 while,不要用 if
while (cnt == 0) /* ✓ */
pthread_cond_wait(¬_empty, &m);
if (cnt == 0) /* ✗ 迟早出事 */
pthread_cond_wait(¬_empty, &m);
课本常说理由是"防止虚假唤醒"(spurious wakeup,wait 有时会无缘无故返回)。这是理由之一,但远不是主要的。
主要的理由是:从"你被叫醒"到"你真正拿到锁并跑起来",中间有一段时间,而这段时间里状态可能又变了。
具体地:
- 消费者甲在等(队列空)。
- 生产者放了一件,发信号,放开锁。
- 甲被标记为可运行——但它还得排队等 CPU,还得重新抢那把锁。
- 就在这时候消费者乙来了,它没在等,直接拿到锁,把那一件取走了。
- 甲终于拿到锁,跑起来。队列又是空的。
用 if 的话,甲会直接往下走,从一个空队列里取东西。
回到银行:叫到你的号,不等于柜台现在归你了。 你从座位上站起来、走到窗口,这中间有几秒钟——而这几秒钟里,一个本来就站在旁边的人可能已经把事办完走了。你走到窗口,发现该办的已经没了。
⭐ 这个模式有个名字,叫 Mesa 语义:信号只是提示"你可以去看看了",不是保证"条件现在成立"。 今天所有主流的条件变量都是 Mesa 语义。
所以那句话应该这么记:signal 的意思不是"条件成立了",而是"条件可能成立了,你自己再查一遍"。 而"再查一遍"的唯一正确写法就是 while。
规矩二:改了状态才发信号,而且要在持锁时改
信号是发给"状态变了"这件事的。你得先改状态,再发信号,而且改状态必须在锁的保护下——否则等待方查到的可能是个中间状态。
规矩三:拿不准就用 broadcast
signal 只叫醒一个,broadcast 叫醒全部。
broadcast 一定正确(醒来的都会重新用 while 查一遍条件,不满足的接着睡),但可能造成惊群——一堆线程醒来抢锁,最后只有一个能干活。
signal 更高效,但只在"所有等待者等的是同一件事、而且醒一个就够"的时候才对。
⚠️ 下一节那个例子会说明用错的后果。
五、为什么要用两个条件变量
上面的代码里,“队列非空"和"队列非满"各有一个条件变量。能不能合成一个?
能,但只能配 broadcast。用 signal 就会死锁:
- 缓冲区满了,两个生产者都在等。
- 消费者取走一件,发
signal。 - 这个信号可能叫醒的是另一个消费者(大家等的是同一个条件变量,内核不知道谁在等什么)。
- 那个消费者醒来一看队列不空?也可能它发现队列空了又睡回去。
- 生产者始终没被叫醒。全体挂住。
⭐ 结论:“谁在等什么"这个信息,要么用不同的条件变量表达,要么用 broadcast 把所有人都叫醒让他们自己查。
这是一条通用的设计原则:当你没法精确表达"该叫谁"时,就得叫所有人。 代价是惊群。
六、代价与取舍
条件变量换来了什么: 等待不烧 CPU。实测四百四十倍。以及一个能精确表达"我在等什么"的机制。
它花了什么:
- 唤醒有延迟。 从
signal到等待者真正跑起来,要经过内核唤醒 + 调度 + 重新抢锁,通常几微秒。竞争极轻、临界区极短的场合,忙等反而更快——这和第 17 篇自旋锁的取舍是同一条线。 - 正确使用它需要遵守一堆规矩,而违反的后果(挂死、惊群、从空队列取数据)都极难复现。
它放弃了什么: 代码的局部可读性。 你看着 pthread_cond_wait 那一行,看不出锁在这里被放开又拿回来了。函数的中间突然出现一个"别的线程可以进来改一切"的时刻——这正是并发代码难读的根源。
更高层的封装(Go 的 channel、Java 的 BlockingQueue、Rust 的 mpsc)本质上就是把上面那三条规矩封装好,不让你有机会写错。能用它们就用它们。
七、小结
- 锁管"别人在用”,条件变量管"条件还不成立”。两个不同的问题。
- ⭐ 实测:一个核上搬十万件,忙等 75 秒,条件变量 0.17 秒——四百四十倍。忙等的墙上时间等于 CPU 时间,全在空转。
wait原子地做三件事:放开锁 → 挂起 → 醒来后重新拿锁。前两步必须原子,否则会错失唤醒、永久挂死。- ⭐ 规矩一:永远用
while不要用if。 主要原因不是虚假唤醒,是 Mesa 语义——从被叫醒到真正跑起来之间,别人可能把状态又改了。signal的意思是"你可以去看看了",不是"条件成立了"。 - 规矩二:持锁改状态,改完再发信号。规矩三:拿不准就
broadcast,代价是惊群。 - ⭐ “谁在等什么"要么用不同的条件变量表达,要么就 broadcast 叫所有人。 一个条件变量配
signal会挂死。 - 代价是唤醒有几微秒延迟(极短临界区时忙等反而快),以及看代码看不出锁在中间被放开过。能用高层封装就用。
思考题
- 第四节那个"甲被叫醒但乙插队"的场景,如果队列容量是 1000 而不是 8,还会发生吗?为什么它依然是个 bug?
- 如果把
pthread_cond_signal挪到pthread_mutex_unlock之后(先放锁再发信号),程序还正确吗?性能会怎样? - 第五节说一个条件变量配
signal会挂死。如果生产者和消费者各只有一个,还会挂死吗?这说明什么? - 银行叫号那个比方里,“Mesa 语义"对应什么?(提示:叫到你的号之后,你走到窗口的路上可能发生什么?)