跳到正文

条件变量与等待通知

环境:Windows 11 x64,GCC 13.1.0(MinGW-w64),g++ -std=c++17 -O2 -Wall -Wextra -static。卡死一律用父进程看门狗 + 两次 CPU 采样判定,不靠肉眼。所有吞吐/校验和都来自真实运行输出,生产者与消费者各自累加同一集合,校验和不匹配就会打印”不匹配!”。

一、为什么 wait 必须配谓词循环

cv.wait(lk) 的真实语义只有一句:“放开锁、睡下;被别人叫醒后重新拿锁、返回。” 它不保证叫醒你的原因就是你等的那件事。所以”先判断再睡”天然是错的:判断和睡下之间有窗口。

两种写法,同一个消费者、同一个生产者,生产者刻意把通知发在消费者”已经判断完、还没睡下”的窗口里(那个窗口在真实代码里只有几纳秒,我这里放大到 3ms 才能稳定复现):

// 危险:判断在锁外
while (!g_item) {
    /* 窗口:此处被插队收到通知 */
    std::unique_lock<std::mutex> lk(m);
    cv.wait(lk);
}

// 正确:判断和睡下是同一次原子动作
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [] { return g_item; });

父进程给 3 秒看门狗:

  consumer: while(!g_item){ 窗口3ms; lock; wait(); }   ⇒ 卡死(3 秒未退出),已强制终止
  consumer: cv.wait(lk, []{return g_item;})             ⇒ 正常退出

丢失的通知不会重发。谓词循环的作用就是把”我是不是该睡”这件事放回到持有锁的同一时刻去判断——wait(lk, pred) 展开就是 while (!pred()) wait(lk);,两者等价,写谓词版只是为了不忘记。

虚假唤醒:本机复现不了,但仍必须防

用一个干净的检测器:4 个线程反复 wait_until(+20ms),全程没有任何人 notify,于是每一次”非超时返回”都必然是虚假唤醒。

  [子进程] 4 线程共 wait_until 512 次(全程无人 notify),虚假唤醒 = 0 次

512 次样本里 0 次。winpthreads 在这台机器上没有表现出虚假唤醒,但这是运行时的运气,不是接口承诺——标准明文允许 wait 无端返回,而且 Linux 的 pthread_cond_signal 在历史上真实出现过。下一小节的数据说明:即便”教科书意义上的虚假唤醒”为 0,工程意义上的无效唤醒每天都在发生,而且量很大。

二、一次唤醒值多少钱,以及 notify 放哪儿

令牌式生产消费交接,2 万轮,每次交接(一觉一醒)的摊薄单价:

  生产者消费者互相交接 20000 轮:457.6 ms ⇒ 每次唤醒交接 11.44 µs

和第一节量的条件变量 ping-pong(11.35µs)对得上,这是”线程真的睡下去再被叫醒”的成本。

那 notify_one() 应该放在解锁之前还是之后?常见建议是”解锁之后再 notify”,理由是唤醒方一出内核就撞锁上。直接量:等对方确实睡沉之后(每轮之间留 60µs 空档),记录 notify 到醒来那一刻的间隔,3000 次样本排序取分位:

  notify 在解锁前:中位唤醒延迟 5.80 µs,平均 10.07 µs,P95 36.00 µs,最大 1038.20 µs
  notify 在解锁后:中位唤醒延迟 5.10 µs,平均 7.69 µs,P95 17.50 µs,最大 116.80 µs

中位数几乎一样,差别全在尾部:P95 从 36µs 缩到 17.5µs,最大单次延迟从 1.04ms 缩到 117µs——9 倍。持锁通知时,被唤醒的线程要排队等通知方走出临界区,一旦临界区里还有别的事,尾延迟就炸开。结论:先 unlock() 再 notify,代价为零,收益是尾部延迟。

三、notify_one、notify_all 与”一个条件变量对应一种等待原因”

一个有界缓冲区,三种写法:

  • 模式 0:一把锁 + 一个条件变量 + notify_all
  • 模式 1:一把锁 + 一个条件变量 + notify_one
  • 模式 2:一把锁 + 两个条件变量(not_empty / not_full),各用 notify_one

容量 256、100 万个元素时,三者都跑得动:

  mode=0 P=1 C=1 cap= 256:   7.45 M/s  等待     3390 次  无效唤醒      0 次  校验和=500000500000 OK
  mode=1 P=1 C=1 cap= 256:   7.88 M/s  等待     3570 次  无效唤醒      0 次  校验和=500000500000 OK
  mode=0 P=4 C=4 cap= 256:   2.04 M/s  等待     4069 次  无效唤醒    187 次  校验和=125000500000 OK
  mode=1 P=4 C=4 cap= 256:   2.60 M/s  等待     8810 次  无效唤醒    576 次  校验和=125000500000 OK
  mode=0 P=4 C=1 cap= 256:   1.68 M/s  等待    39189 次  无效唤醒   6940 次  校验和=125000500000 OK
  mode=1 P=4 C=1 cap= 256:   1.41 M/s  等待    55933 次  无效唤醒   9894 次  校验和=125000500000 OK

“无效唤醒”= 被叫醒后重新拿锁、发现谓词仍然不成立、只能再睡回去。单条件变量版本在 4 生产 1 消费时,约 18% 的等待是白醒的(6940/39189、9894/55933)。这就是”虚假唤醒”的实用等价物:不需要内核出错,只要等待原因混在一个队列里,浪费自然产生。

把容量压到 1,模式 1 直接死锁

  mode=0 P=2 C=2 cap=   1:  0.02 M/s  等待 740615 次  无效唤醒 352166 次  校验和=10000100000 OK   (墙钟 8486 ms)
  mode=1 P=2 C=2 cap=   1:  60 秒仍未结束
  mode=2 P=2 C=2 cap=   1:  0.14 M/s  (墙钟 1649 ms)  校验和=10000100000 OK
  mode=1 P=4 C=1 cap=   1:  60 秒仍未结束

“60 秒仍未结束”可能是慢,也可能是卡死,必须区分。对这个卡住的进程间隔 8 秒采两次 CPU 时间:

alive=True cpu1=0 cpu2=0 delta=0 threads=6

18 秒里总共消耗 0.000 秒 CPU、6 个线程都活着——不是慢,是所有人都在睡,事件图上再没有任何通知会发出。这是真死锁。

机制很简单:一个条件变量上同时挂着”因为满了在等的生产者”和”因为空着在等的消费者”。notify_one 只随机挑一个唤醒,挑中了同类(生产者通知生产者),它看一眼条件不成立就睡回去,而这次通知被消耗掉了——那个本该被叫醒的人永远等不到。容量 256 时缓冲总有富余,系统还能靠后续通知撑住;容量 1 时每次交接都是唯一的一次通知机会,于是必然踩中。

所以规则不是”notify_all 更安全”这么简单,而是:

一个条件变量 = 一种等待原因。 有两种等待原因(非空、非满)就该有两个条件变量。

配了正确的条件变量,notify_one 才既高效又安全(上表 mode=2 在 cap=1 下 1.6 秒跑完)。如果你只用一个条件变量,那你必须 notify_all,代价就是 47%(352166/740615)的唤醒是白做的,墙钟从 1.6 秒涨到 8.5 秒。

补一句读数据的诚实说明:模式 2 的”无效唤醒”列显示 0,是因为它用 wait(lk, pred) 谓词形式,重试被藏在 wait 内部,我的计数器插不进去——这不代表它没有无效唤醒,只代表我没法用同一个位置去数它。

四、真正的主角是缓冲容量,不是同步原语

同样用双条件变量(模式 2),1 生产 1 消费,只变容量:

  cap=   1:  0.10 M/s
  cap=   2:  0.15 M/s
  cap=  16:  1.12 M/s
  cap= 256:  7.15 M/s
  cap=4096:  8.09 M/s

从 1 到 256 是 71 倍。原因不是锁更闲,而是交接次数变了:容量 1 时每个元素都要”一次睡眠 + 一次唤醒”(本篇第三小节的 11.44µs 单价),容量 256 时两边可以连续做 256 个元素才碰上一次真正的等待。同步原语的差异(本篇第三小节的 2.04/2.60/2.54 M/s,只有 20–30%)在这个 71 倍面前是次要矛盾。

这条经验的通用形式:先想办法让等待变少(缓冲、批量、水位线),再考虑把等待变快(换锁、换原子)。

五、超时等待的时间精度:1512 倍的误差

wait_for / wait_until 在无人通知的情况下应当刚好睡到点就醒。实测中位数(各 5 次):

  名义     10 µs:wait_for 中位   15123.0 µs(1512 倍)  wait_until 中位   15261.9 µs(1526.2 倍)
  名义     50 µs:wait_for 中位   15216.1 µs( 304 倍)  wait_until 中位   15219.2 µs( 304.4 倍)
  名义    200 µs:wait_for 中位   15408.4 µs(  77 倍)  wait_until 中位   15530.2 µs(  77.7 倍)
  名义   1000 µs:wait_for 中位   15254.5 µs(  15 倍)  wait_until 中位   15300.3 µs(  15.3 倍)
  名义   5000 µs:wait_for 中位   14935.6 µs(   3 倍)  wait_until 中位   15847.0 µs( 3.2 倍)
  名义  20000 µs:wait_for 中位   30050.0 µs(   2 倍)  wait_until 中位   30492.2 µs( 1.5 倍)

绝对期限(wait_until)并不比相对超时更准——两者都被系统定时器 tick(约 15.6ms)向上取整。任何小于一个 tick 的超时都会变成”睡整整一个 tick”,20ms 的请求会落到两个 tick(30ms)。这和第二节 try_lock_for 的现象同源,也是第一节 Sleep(1) 实测 15ms 的同一个根因。

一个重要例外:这是”没人抬高时钟分辨率时”的默认行为。同进程里只要有一个活动的高分辨率定时器,Windows 就把该进程的 tick 抬到 1ms——本专栏 Qt 篇实测 QTimer(1ms) 稳定跑出 1.05ms,而同一台机器裸 Sleep(1) 仍是 14.9ms。我自己在第一节也量到过这个开关:调 timeBeginPeriod(1) 后 Sleep(1) 从 15ms 变 1.88ms。所以这些数字要这样读:15.6ms 是进程默认值,不是系统常数;在 Qt/游戏引擎这类会主动设置分辨率的框架里,同一个 wait_for(10µs) 可能落到 1ms 级。反过来,如果你的库依赖”tick 是 15.6ms”做退避节奏,一旦宿主程序抬了分辨率,你的节奏就会变密。

影响:不要用”短超时 + 重试”实现任何等待逻辑。想等 200µs 你就得不到 200µs,你会得到 15ms(或被宿主抬到 1ms,仍然不是 200µs);一个”每秒重试 5000 次”的循环会变成每秒 64 次或 1000 次,取决于宿主。超时只适合当作兜底上下界(例如”3 秒还没消息就认定对端掉了”),不适合当作节奏器。

六、第一个正确的生产者-消费者

把上面几条合成一个可以直接抄的骨架:

template <class T>
class BoundedQueue {
    std::mutex m_;
    std::condition_variable not_empty_, not_full_;   // 两种等待原因,两个条件变量
    std::vector<T> buf_;
    size_t head_ = 0, tail_ = 0, count_ = 0, cap_;
    bool closed_ = false;

public:
    explicit BoundedQueue(size_t cap) : buf_(cap), cap_(cap) {
        if (cap_ == 0) throw std::invalid_argument("cap must be > 0");
    }

    void close() {                                  // 只有生产者能调,且只调一次
        { std::lock_guard<std::mutex> g(m_); closed_ = true; }
        not_empty_.notify_all();                    // 唤醒全部消费者:退出原因不是"有数据"
    }

    bool push(T v) {
        std::unique_lock<std::mutex> lk(m_);
        not_full_.wait(lk, [&] { return count_ < cap_ || closed_; });
        if (closed_) return false;                    // 已关闭:丢弃并告知上层
        buf_[tail_] = std::move(v);
        tail_ = (tail_ + 1) % cap_;
        ++count_;
        lk.unlock();
        not_empty_.notify_one();                      // 先解锁再通知(本篇第二小节)
        return true;
    }

    bool pop(T& out) {
        std::unique_lock<std::mutex> lk(m_);
        not_empty_.wait(lk, [&] { return count_ > 0 || closed_; });
        if (count_ == 0) return false;                // 已关闭且存量取完
        out = std::move(buf_[head_]);
        head_ = (head_ + 1) % cap_;
        --count_;
        lk.unlock();
        not_full_.notify_one();
        return true;
    }
};

四个容易被写错的点,全都有本节实测支撑:

  1. 谓词里必须带上 closed_——wait(lk, pred) 会一直睡到 pred() 为真,谓词里没有关闭条件,关闭时睡着的线程就永远醒不来(本篇第一小节的丢失唤醒)。另外注意只有 wait_for/wait_until 的返回值才区分”被通知”还是”超时”,谓词版 wait 的返回值就是谓词本身,别拿它当”是否超时”用。
  2. 关闭时消费者要 notify_all:要退出的是”所有消费者”这种全体语义,notify_one 只能放一个,其余会永远睡在 not_empty_ 上。生产者侧则相反,每个元素只够一个消费者取,notify_one 才是对的。
  3. 关闭之后缓冲里可能还有数据(上面靠 if (count_ == 0) 这一句兜住),“队列关了”不等于”队列空了”,消费者要先把存量取完再退出。
  4. 先 unlock 再 notify(本篇第二小节:尾延迟差 9 倍)。

优雅退出的完整协议里还有一条:close() 只能由生产者调、且只能调一次;多个生产者需要外部再广播一次关闭,这是第七节《并发设计模式》和第六节《线程池》的内容。

这份代码本身也要验

上面这份 BoundedQueue 不是写完就贴出来的:20 万个全局唯一 id灌进去,取出端一边收一边给每个 id 计数,收完检查总数、重复数、校验和(Σ0..199999)。16 种并发度 × 容量的组合全部通过:

  P=2 C=2 cap=   1 推入 200000 取出 200000 重复 0 未推入 0  checksum OK
  P=4 C=4 cap=   1 推入 200000 取出 200000 重复 0 未推入 0  checksum OK
  P=4 C=1 cap=   1 推入 200000 取出 200000 重复 0 未推入 0  checksum OK
  P=1 C=4 cap=   1 推入 200000 取出 200000 重复 0 未推入 0  checksum OK
  ……(cap=2 / 16 / 256 各同样 4 种并发度,共 16 组,全部 OK)

本篇第三小节那个把 notify_one 挂在单个条件变量上的版本,在 P=2 C=2 cap=1 下 60 秒跑不完;换成这份实现,同样配置每次都跑完且校验和正确。关闭语义也单独验了:

  close() 之后 push 返回 0(0=false),pop 返回 0(0=false)
  关闭前缓冲里还有 2 个,close 后取到 2 个(7 9)⇒ 关不等于空

七、小结

  • wait 只保证”被叫醒”,不保证”因何被叫醒”,所以判断必须写成谓词、且必须在持锁时判断。判断写在锁外,实测会永久卡死(看门狗 3 秒超时并强杀)。
  • 虚假唤醒本机 512 次观测 0 次,但无效唤醒是常态:单条件变量 + 4 生产 1 消费时高达 18%(9894/55933)的唤醒白做。
  • 一个条件变量对应一种等待原因;否则只能 notify_all,代价是实测 8.5 秒 vs 1.6 秒。单条件变量 + notify_one 在容量 1 时真死锁(CPU 采样 18 秒 0.000 秒)。
  • notify 放解锁之后:中位数没差别,P95 从 36µs → 17.5µs,最大 1.04ms → 117µs。
  • 容量比同步原语重要得多:1 → 256 带来 71 倍吞吐,而三种同步写法的差异只有 20–30%。
  • 超时等待被 15.6ms 的 tick 支配:wait_for(10µs) 实测 15.1ms,wait_until 一样。超时只能当兜底,不能当节奏器。