条件变量与等待通知
环境: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;
}
};
四个容易被写错的点,全都有本节实测支撑:
- 谓词里必须带上
closed_——wait(lk, pred)会一直睡到pred()为真,谓词里没有关闭条件,关闭时睡着的线程就永远醒不来(本篇第一小节的丢失唤醒)。另外注意只有wait_for/wait_until的返回值才区分”被通知”还是”超时”,谓词版wait的返回值就是谓词本身,别拿它当”是否超时”用。 - 关闭时消费者要
notify_all:要退出的是”所有消费者”这种全体语义,notify_one只能放一个,其余会永远睡在not_empty_上。生产者侧则相反,每个元素只够一个消费者取,notify_one才是对的。 - 关闭之后缓冲里可能还有数据(上面靠
if (count_ == 0)这一句兜住),“队列关了”不等于”队列空了”,消费者要先把存量取完再退出。 - 先
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一样。超时只能当兜底,不能当节奏器。