本篇位置 锁管"互斥",条件变量管"等待"。两者解决的是不同的问题
运行环境 容器里的 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 这一个调用做了三件事,而且是原子地做的

  1. 放开你手里的锁
  2. 把自己挂起(进入第 2 篇那个阻塞态)。
  3. 被叫醒之后,重新拿到那把锁,才返回。

第 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(&not_full, &m);
        buf[put] = i; put = (put + 1) % CAP; cnt++;
        pthread_cond_signal(&not_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(&not_empty, &m);
        get = (get + 1) % CAP; cnt--;
        pthread_cond_signal(&not_full);       /* 告诉生产者:有空位了 */
        pthread_mutex_unlock(&m);
    }
}

注意用了两个条件变量。第五节说为什么。

四、⭐ 三条规矩

规矩一:永远用 while,不要用 if

while (cnt == 0)                     /* ✓ */
    pthread_cond_wait(&not_empty, &m);

if (cnt == 0)                        /* ✗ 迟早出事 */
    pthread_cond_wait(&not_empty, &m);

课本常说理由是"防止虚假唤醒"(spurious wakeup,wait 有时会无缘无故返回)。这是理由之一,但远不是主要的

主要的理由是:从"你被叫醒"到"你真正拿到锁并跑起来",中间有一段时间,而这段时间里状态可能又变了。

具体地:

  1. 消费者甲在等(队列空)。
  2. 生产者放了一件,发信号,放开锁
  3. 甲被标记为可运行——但它还得排队等 CPU,还得重新抢那把锁
  4. 就在这时候消费者乙来了,它没在等,直接拿到锁,把那一件取走了。
  5. 甲终于拿到锁,跑起来。队列又是空的。

if 的话,甲会直接往下走,从一个空队列里取东西

回到银行:叫到你的号,不等于柜台现在归你了。 你从座位上站起来、走到窗口,这中间有几秒钟——而这几秒钟里,一个本来就站在旁边的人可能已经把事办完走了。你走到窗口,发现该办的已经没了。

⭐ 这个模式有个名字,叫 Mesa 语义信号只是提示"你可以去看看了",不是保证"条件现在成立"。 今天所有主流的条件变量都是 Mesa 语义。

所以那句话应该这么记:signal 的意思不是"条件成立了",而是"条件可能成立了,你自己再查一遍"。 而"再查一遍"的唯一正确写法就是 while

规矩二:改了状态才发信号,而且要在持锁时改

信号是发给"状态变了"这件事的。你得先改状态,再发信号,而且改状态必须在锁的保护下——否则等待方查到的可能是个中间状态。

规矩三:拿不准就用 broadcast

signal 只叫醒一个,broadcast 叫醒全部。

broadcast 一定正确(醒来的都会重新用 while 查一遍条件,不满足的接着睡),但可能造成惊群——一堆线程醒来抢锁,最后只有一个能干活。

signal 更高效,但只在"所有等待者等的是同一件事、而且醒一个就够"的时候才对

⚠️ 下一节那个例子会说明用错的后果。

五、为什么要用两个条件变量

上面的代码里,“队列非空"和"队列非满"各有一个条件变量。能不能合成一个?

能,但只能配 broadcast。用 signal 就会死锁:

  1. 缓冲区满了,两个生产者都在等。
  2. 消费者取走一件,发 signal
  3. 这个信号可能叫醒的是另一个消费者(大家等的是同一个条件变量,内核不知道谁在等什么)。
  4. 那个消费者醒来一看队列不空?也可能它发现队列空了又睡回去。
  5. 生产者始终没被叫醒。全体挂住。

⭐ 结论:“谁在等什么"这个信息,要么用不同的条件变量表达,要么用 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 会挂死。
  • 代价是唤醒有几微秒延迟(极短临界区时忙等反而快),以及看代码看不出锁在中间被放开过能用高层封装就用。

思考题

  1. 第四节那个"甲被叫醒但乙插队"的场景,如果队列容量是 1000 而不是 8,还会发生吗?为什么它依然是个 bug?
  2. 如果把 pthread_cond_signal 挪到 pthread_mutex_unlock 之后(先放锁再发信号),程序还正确吗?性能会怎样?
  3. 第五节说一个条件变量配 signal 会挂死。如果生产者和消费者各只有一个,还会挂死吗?这说明什么?
  4. 银行叫号那个比方里,“Mesa 语义"对应什么?(提示:叫到你的号之后,你走到窗口的路上可能发生什么?)

延伸