condition_variable
2. condition_variable 如何实现事件驱动
核心是:等待线程在内核里睡眠,不占用 CPU;通知线程把它唤醒。 等待期间,这个线程从调度器的运行队列里摘掉,完全不会被调度。条件满足后由通知方把它放回运行队列。这就是"事件驱动",与之相对的"轮询"是线程自己反复醒来检查条件。
| 方式 | 等待期间 CPU | 响应延迟 |
|---|---|---|
忙等 while (!ready); |
占满一个核 | 最低 |
睡眠轮询 while (!ready) sleep(10ms); |
周期性醒来 | 最多多等一个轮询周期 |
condition_variable |
0 | 一次唤醒的开销(系统调用加调度,微秒级) |
用法
std::mutex m;
std::condition_variable cv;
bool ready = false;
// 等待方
std::unique_lock ;
cv.; // 等价于 while (!ready) cv.wait(lk);
// 通知方
cv.;
底层实现:Linux 上是 futex
libstdc++ 的 std::condition_variable 封装的是 pthread_cond_t,glibc 用 futex 实现它。futex 有两个操作:
futex_wait(addr, expected):内核在锁住等待队列后检查*addr == expected,相等才睡,不等就立即返回。futex_wake(addr, n):唤醒在addr上睡眠的最多 n 个线程。
关键难点:唤醒丢失。 wait 要做两件事:释放 mutex,然后睡眠。如果通知恰好发生在这两步之间,等待方就会错过通知,永远睡下去。
futex 的"相等才睡"语义正好能解决这个问题。条件变量内部有一个序号,每次通知都会把它加一:
等待线程 通知线程
lock(m)
检查 ready == false
seq0 = cond.seq ← 持锁时记下序号
unlock(m)
lock(m); ready = true; unlock(m)
cond.seq++; futex_wake(&cond.seq) ← 这时还没人在睡
futex_wait(&cond.seq, seq0)
内核检查:seq != seq0 → 立即返回,不睡 ← 通知没有丢
lock(m),再次检查 ready == true,wait 返回
序号在释放 mutex 之前就读好了。所以无论通知落在哪个时间点,要么等待方还没睡、futex_wait 发现序号变了直接返回,要么等待方已经睡着、被 futex_wake 叫醒。两种情况都不会丢通知。
内核这边:
futex_wait把线程挂到按地址哈希的等待队列上,把线程状态设为睡眠,然后调用schedule()让出 CPU。futex_wake找到对应队列,把线程放回运行队列。
为什么必须用 mutex 加 while 循环
- mutex:保护条件本身(
ready),保证"检查条件然后睡"和"修改条件然后通知"这两组操作不会交错。 - while 循环(或带谓词的
wait):被唤醒不代表条件一定成立,有两种情况:- 虚假唤醒:futex 可能因为信号等原因提前返回。
- 被抢先:
notify_one唤醒了你,但在你重新拿到 mutex 之前,另一个线程已经把条件消费掉了。
实现细节
- glibc 2.25 起的真实实现更复杂:用 64 位的
__wseq和两组等待者(G1/G2),保证不会唤醒"通知之后才开始等待"的线程。核心仍然是上面的"比较后再睡"。 - 没有等待者时,
notify_one只在用户态检查一下计数就返回,不进内核。 - 在锁外 notify 可以避免一个问题:被唤醒的线程马上又因为拿不到 mutex 而阻塞。在锁内或锁外 notify 都是正确的。
- C++20 的
std::atomic<T>::wait/notify直接建立在 futex 上,比条件变量更轻,适合"等某个原子变量变化"的简单场景。
上面的 futex 调用序列和 glibc 实现细节来自源码知识,这次没有实测。要看实际的系统调用,可以在 dev 上 strace 一个生产者-消费者程序,能看到 futex(..., FUTEX_WAIT_BITSET_PRIVATE, ...) 和 FUTEX_WAKE_PRIVATE 成对出现。
暂无评论,欢迎留下第一条评论。