条件变量:厨师之间的递话筒

开篇除恐

当人们听到“条件变量”(Condition Variable)时,常会想到又是一堆陌生的函数:pthread_cond_wait, std::condition_variable, notify_one……似乎只有读了操作系统教材才能明白。其实,条件变量只不过是厨师之间的“递话筒”:厨师 A 正在切菜,但需要等待厨师 B 把调料准备好;此时 A 不必忙循环浪费 CPU,而是把自己放在递话筒上说:“我等调料。”厨师 B 在调料准备好后,拿起递话筒说:“调料好了,你可以继续切菜。”这样,双方就能在不浪费资源的情况下协调工作进度。

白话化

  • 条件变量(Condition Variable):一个用于让线程等待某个条件变为真的同步原语。它本身不存储条件,而是配合一个互斥锁和一个谓词(布尔条件)使用。
  • 典型使用模式
    1. 线程锁住互斥锁;
    2. 在锁住的状态下检查谓词(例如 while not ready);
    3. 如果谓词为假,调用 cond.wait();这会释放互斥锁并让线程进入等待状态;
    4. 当另一线程使谓词变为真后,调用 cond.notify_one()cond.notify_all()
    5. 被唤醒的线程会重新获取互斥锁,重新检查谓词(以防虚假唤醒),然后继续执行。
  • 为什么需要互斥锁?:条件变量的等待和通知必须在同一个互斥锁的保护下进行,以免在检查谓词和进入等待之间出现竞态。
  • 虚假唤醒(Spurious Wakeup):即使没有人调用 notify,等待的线程也可能莫名其妙地被唤醒。因此,等待时必须总是用 while not predicate 而不是 if

简而言之:条件变量让线程在条件不满足时挂起,省去忙循环的CPU浪费;在条件满足时通过通知唤醒一个或所有等待者。

直觉先行

想象你们俩在准备一道汤: - 你负责切蔬菜,需要等待汤底已经煮好才能把蔬菜下锅; - 你的伙伴负责煮汤底,汤底煮好后会喊你一声。

如果你不使用递话筒,你可能会一直站在锅边问:“汤底好了吗?”每隔几秒钟就问一次,浪费时间和能量。而如果你有递话筒,你就可以说:“我等您通知。”伙伴在汤底煮好后,只需说一声:“汤底好了,您可以下蔬菜了。”这样,你就可以去做其他事情(比如准备调料),而不必一直盯着锅。

在编程里,共享的布尔变量 soup_ready 就是汤底是否好的标志,互斥锁保证只有一方在修改或检查这个标志时另一方不会同时修改,条件变量则是递话筒:等待者在条件不满足时挂起,通知者在条件变为真后发出信号。

例子贴身

例子 1(☼ 热身):生产者‑消费者的简单模型(有界缓冲区)

任务:一个生产者线程不断向一个大小为 5 的环形缓冲区放入整数;两个消费者线程从缓冲区取出整数并打印。当缓冲区满时生产者等待;当缓冲区空时消费者等待。

怎么想到的:我们用一个列表 buffer 作为环形队列,max_size = 5in_idxout_idx 为写入/读取位置,size 表示当前元素数量。互斥锁 mutex 保护对这些共享变量的访问。两个条件变量:not_full(当 size < max_size 时可生产)和 not_empty(当 size > 0 时可消费)。生产者在放入元素后通知 not_empty;消费者在取出后通知 not_full

示例代码(Python)

import threading
import time

class BoundedBuffer:
    def __init__(self, capacity: int):
        self.capacity = capacity
        self.buffer = [None] * capacity
        self.in_idx = 0
        self.out_idx = 0
        self.size = 0
        self.mutex = threading.Lock()
        self.not_empty = threading.Condition(self.mutex)
        self.not_full = threading.Condition(self.mutex)

    def produce(self, item):
        with self.not_full:
            while self.size >= self.capacity:
                self.not_full.wait()
            self.buffer[self.in_idx] = item
            self.in_idx = (self.in_idx + 1) % self.capacity
            self.size += 1
            self.not_empty.notify()

    def consume(self):
        with self.not_empty:
            while self.size <= 0:
                self.not_empty.wait()
            item = self.buffer[self.out_idx]
            self.out_idx = (self.out_idx + 1) % self.capacity
            self.size -= 1
            self.not_full.notify()
            return item

def producer(buf, pid):
    for i in range(20):
        buf.produce(i)
        print(f"Producer {pid} produced {i}")
        time.sleep(0.03)  # 模拟生产间隔

def consumer(buf, cid):
    for _ in range(20):
        item = buf.consume()
        print(f"Consumer {cid} consumed {item}")
        time.sleep(0.05)  # 模拟消费间隔

def main():
    buf = BoundedBuffer(5)
    p1 = threading.Thread(target=producer, args=(buf, 1))
    c1 = threading.Thread(target=consumer, args=(buf, 1))
    c2 = threading.Thread(target=consumer, args=(buf, 2))
    p1.start()
    c1.start()
    c2.start()
    p1.join()
    c1.join()
    c2.join()

if __name__ == "__main__":
    main()

运行结果摘录(生产和消费交替进行,缓冲区永不越界)

Producer 1 produced 0
Consumer 1 consumed 0
Consumer 2 consumed 1
Producer 1 produced 1
...

要点
- 条件变量的等待必须放在 while 循环中,以应对虚假唤醒。
- wait 会自动释放互斥锁,并在被唤醒后重新获取锁,确保对共享状态的独占访问。
- 生产者‑消费者模式是许多并发场景的基础(日志系统、任务队列、音视频流缓冲等),掌握它后你就能够构建更复杂的线程协作。

例子 2(☼☼ 正经):带超时的等待(模拟点餐最长等待时间)

任务:主线程发起一个子线程去做一些可能耗时不定的工作(比如从网络获取数据),但最多只愿意等待 2 秒;超时后继续执行后备方案。

怎么想到的:我们使用 threading.Condition.wait(timeout) 来实现带超时的等待。子线程工作完成后设置一个标志 ready 并调用 notify_one;主线程在条件变量上等待最多 2 秒,若在此时收到通知则认为工作完成,否则执行后备逻辑。

示例代码(Python)

import threading
import time

cond = threading.Condition()
ready = False

def worker():
    # 模拟不定时工作
    time.sleep(1.5)  # 假设有时快
    with cond:
        ready = True
        cond.notify_one()

def main():
    t = threading.Thread(target=worker)
    t.start()
    with cond:
        if cond.wait(timeout=2.0):  # 最多等待 2 秒
            print("工作已完成,继续后处理")
        else:
            print("等待超时,执行后备方案")
            # 这里可以决定是否仍要让子线程继续,或尝试自行处理结果
    # 无论哪种情况,最后确保子线程结束
    t.join()

if __name__ == "__main__":
    main()

运行结果(工作 1.5 s < 超时 2.0 s)

工作已完成,继续后处理

如果把工作时间改为 3.0 s,则会输出等待超时的分支。

要点
- 带超时的等价等价让程序保持响应性,避免因某个线程卡死而无限阻塞。
- 在实际使用中,如果超时后仍需要子线程的结果,必须自己设计某种机制(比如让子线程把结果写入共享内存或队列,主线程在超时后检查是否已写好)。

例子 3(☼☼☼ 硬骨头):使用条件变量实现简单的“事件”对象

任务:实现一个可重复使用的事件(类似 Windows 的 Event 对象):线程可以调用 wait() 等待事件被置位;任意线程可以调用 set() 置位事件,使所有等待者立即唤醒;再调用 reset() 将事件复位为未置位状态。

怎么想到的:我们内部使用一个 threading.Conditionthreading.Lock 和一个布尔标志 signaled_wait() 在标志为假时等待;set() 将标志设为真并调用 notify_all()reset() 将标志设为假(不需要通知,因为等待者只在为真时继续)。

示例代码(Python)

import threading

class Event:
    def __init__(self):
        self._cond = threading.Condition()
        self._signaled = False

    def set(self):
        with self._cond:
            self._signaled = True
            self._cond.notify_all()

    def reset(self):
        with self._cond:
            self._signaled = False

    def wait(self):
        with self._cond:
            while not self._signaled:
                self._cond.wait()

def main():
    evt = Event()
    def waiter(wid):
        print(f"Waiter {wid} waiting...")
        evt.wait()
        print(f"Waiter {wid} got signal!")

    def setter():
        time.sleep(1)
        print("Setter: setting event")
        evt.set()

    w1 = threading.Thread(target=waiter, args=(1,))
    w2 = threading.Thread(target=waiter, args=(2,))
    s = threading.Thread(target=setter)
    w1.start()
    w2.start()
    s.start()
    w1.join()
    w2.join()
    s.join()

if __name__ == "__main__":
    main()

运行结果

Waiter 1 waiting...
Waiter 2 waiting...
Setter: setting event
Waiter 1 got signal!
Waiter 2 got signal!

要点
- 条件变量配合布尔标志可以轻松实现事件、信号量等高级同步原语。
- 注意wait必须在锁住互斥锁的情况下调用,并且退出时会重新获取锁。
- 条件变量本身不存储状态,因此必须始终有对应的谓词(布尔变量)来判断是否真的满足条件。

收尾

这一章要带走的东西
- 条件变量让线程在条件不满足时挂起,避免忙循环浪费 CPU;在条件满足时通过 通知 唤醒一个或所有等待者。
- 使用条件变量的标准模式是:锁互斥锁 → 检查谓词(while) → 若不满足则 wait(释放锁并挂起) → 被唤醒后重新获取锁并重新检查谓词
- 必须使用 互斥锁 来保护谓词的读写,否则会出现竞态。
- 条件变量可能出现虚假唤醒,因此等待时必须总是用 while not predicate 而不是 if
- 常见的应用包括:生产者‑消费者、读写锁、事件、线程池任务队列等。掌握条件变量后,你就能够构建更复杂的线程协作模式。
- 下一章我们将介绍线程池:为什么不必为每个短任务都创建新线程,而是复用一组固定的线程来提升效率,以及如何使用标准库或简单实现一个线程池。

就这样。 下一章我们来学习“线程池:别一直雇佣厨师,叫外卖吧”,看看如何让程序在需要时快速获取空闲线程,避免频繁创建和销毁线程的开销。