☰
C++11条件变量实战:从wait/notify到生产者消费者队列
2026/10/20 23:35:18 网站建设 项目流程

如果你写过几天多线程代码,大概率会撞上一个特别尴尬的场面:一个线程在等另一个线程准备好数据,但不知道该立刻返回、睡一会儿还是死等。网上的方案五花八门,有人用while (true) sleep(10ms),有人干脆搞一个“全局标志位”,结果一上生产就出现 CPU 飙高、响应迟钝、偶尔丢失通知的灵异问题。C++11 的std::condition_variable就是专门来解决这类“线程间等待与通知”问题的同步原语,它配合std::mutex,能把“检查条件—进入睡眠—被唤醒—重新检查”这条链路做成原子操作,既不会忙等浪费 CPU,也不会漏掉条件成立的通知。

这篇内容不是抄 cppreference,而是一份实战向的拆解。我会先从条件变量到底解决了什么底层问题讲起,再带你写一个能直接落地的生产者-消费者队列,然后重点聊一聊那些文档里很容易被忽略、但线上事故基本都出在这儿的细节:虚假唤醒、谓词重查、超时语义、丢失唤醒。不管你是刚接触并发的新手,还是已经写过不少thread、但对condition_variable始终有点“会写但不敢说稳”的老手,这篇都值得你花十分钟看完,最好再照着代码敲一遍。

1. 为什么“线程等待某个条件”会这么难搞

1.1 先看一段最容易写崩的原始代码

假设我们现在有两个线程:一个生产者往队列里放任务,一个消费者从队列里取任务。最初的直觉写法往往长这样:

std::queue<int> q; bool has_task = false; void producer() { q.push(1); has_task = true; } void consumer() { while (true) { if (has_task) { // 处理任务 q.pop(); has_task = false; } } }

这段代码的问题不用我说你也看得出来:消费者在疯狂空转,一个核的占用率直接拉满,但真正干活的时间不到千分之一。更麻烦的是,如果你改成sleep(10),任务来了之后消费者可能要额外睡 10 毫秒才反应,实时性烂到家;睡 1 毫秒又跟没睡差不多。

这就是“等待/通知”这个问题的第一层矛盾:既有阻塞能力,又要低延迟,还不能漏信号。

1.2 自旋、标志位与丢失唤醒的本质

比空转更隐蔽的写法是“标志位 + 睡眠”的组合。比如生产者把数据放好之后写一个ready = true,消费者每隔一段时间读一次ready。听起来还算合理,但它存在一个致命窗口:如果生产者在消费者刚检查完ready、还没来得及睡的时候把标志设为了true,那消费者可能就一睡不醒——这个事件永远错过了,这就是经典的 lost wakeup(丢失唤醒)。

丢失唤醒的根源在于:“检查条件”和“进入睡眠”是两个分离的操作,中间隔着的任何一小段时间,都可能插入另一线程的状态变化。要根治它,必须让“检查条件 + 挂起等待”变成一个原子操作:要么条件已经满足,线程直接继续走;要么条件不满足,线程挂起,同时原子的释放锁,让别的线程能改变条件并通知它。

这正是std::condition_variable存在的意义。它专门把一个线程挂在一棵“等待树”上,并由另一个线程通过notify_one()/notify_all()把它唤醒。底层在 Linux 上通常是对futex的一层封装,在 Windows 上则对应内部的事件对象,但标准库把平台差异全部抹平了,你用 C++11 的标准 API,就能获得一致的行为。

1.3 条件变量不是单独用的,它必须搭配互斥锁

很多初学者会发现一个奇怪现象:std::condition_variable::wait()不接受std::lock_guard,只接受std::unique_lock。这不是 API 设计得别扭,而是因为条件变量的核心机制决定了它必须能在等待期间释放锁、在唤醒后再重新拿回锁。unique_lock这种“可以手动lock()/unlock()”的锁适配器,天生就是为了这种场景准备的。

具体来说,cv.wait(lk)做的事情其实是三步:

  1. 原子地释放当前持有的互斥锁;
  2. 把当前线程挂起,等待通知;
  3. 被唤醒后,重新尝试获取同一个互斥锁,然后再返回。

为什么必须这样?因为条件本身通常就是一份受互斥锁保护的共享数据。消费者要判断“队列里有没有任务”,必须先加锁读队列;但它又不能一直握着锁去睡,否则生产者永远没办法往队列里写数据。所以等待期间要把锁交出去,睡醒了再把锁拿回来,去重新看队列的状态。只有“释放锁 + 挂起”这两步是原子的,才能堵住前面说的丢失唤醒窗口。

2. 从 wait/notify 的底层机制看 API 设计

2.1 标准 wait 的两个版本:裸等待与谓词等待

std::condition_variable的wait有两个重载,这是整个 API 最值得先分清的地方:

void wait(std::unique_lock<std::mutex>& lock); template <class Predicate> void wait(std::unique_lock<std::mutex>& lock, Predicate pred);

裸版本cv.wait(lk)只负责“睡下再醒来”,但这意味着调用者要自己在外面包一层条件判断,而且通常是while循环而不是if。第二个带谓词的版本,内部实现几乎等价于一个while (!pred()) cv.wait(lk);循环。也就是说,条件变量本身不会替你记忆任何“状态”,它只是个闹钟,负责把你叫起来,至于起来之后是不是真的该干活了,你得自己确认。

这里我建议你养成一个条件反射:所有用到wait的地方,都优先用带谓词的重载。它一方面省得你手写循环,另一方面也把虚假唤醒的风险消掉一大半。后面第三节我再详细展开。

2.2 notify_one 与 notify_all 的选择

notify_one()只唤醒等待队列里的一个线程,notify_all()唤醒所有等待在该条件变量上的线程。大多数教程会告诉你“多消费者用notify_all,单消费者用notify_one”,这句话没错,但实际线上场景还要看条件本身的性质。

如果你的多个消费者都等在同一个条件变量上,但任务每次只需要一个人去处理,那用notify_all()会引发惊群效应:所有线程醒来,先抢锁,抢到了的看一眼队列发现问题还没轮到自己,又继续睡。多几次这样的操作,线程调度开销和工作线程的无效唤醒就会拖垮性能。反之,如果条件满足时需要多个线程同时开始干活(比如“门开了,所有人都出去”),那必须用notify_all()。

我在实践中一般遵循这个原则:

  • 条件代表“队列非空 / 资源可用 / 单个事件完成”→ 用notify_one();
  • 条件代表“关闭信号 / 打开闸门 / 所有人一起醒”→ 用notify_all()。

2.3 在锁内 notify 还是在锁外 notify?

这也是个频繁被拿出来讨论的问题。先给结论:两种写法都能拿对语义,但无事发生。

在持锁的情况下调用notify_one(),被通知的线程不能立刻真正运行,只能先醒过来去抢锁,而锁还握在当前线程手里,于是唤醒动作被延后到当前线程解锁之后。这不影响正确性,但在某些实现里会造成一点无用功,也容易让测延迟的人产生正在“假装并发”的错觉。我自己的习惯是:一律先释放锁,再发通知,代码长这样:

void push(int v) { { std::lock_guard<std::mutex> lk(mutex_); queue_.push(v); } // 先解锁 cv_.notify_one(); // 再通知 }

这么做最大的好处是:通知时没有锁竞争,被唤醒的消费者能立刻继续抢锁,整个通知到消费的链路更短。有些多生产者高并发场景,把 notify 移到锁外,延迟甚至能肉眼可见地降下来。

不过有一点需要留意:如果你的业务逻辑要求在通知之后、其他线程动作之前,还要保持某个共享数据不变,那把其中一部分逻辑放到锁外就要重新想清楚。不要为了玄学的性能优化,破坏数据一致性。

2.4 为什么 wait 要求 unique_lock 而不是 lock_guard

前面提到,wait()期间要释放锁,醒来要重拿锁,lock_guard从构造到析构都不允许手动解锁,所以根本配合不了。unique_lock可以在构造时选择延迟加锁、随时手动unlock()/lock(),这些特性让它在条件变量场景里无可替代。

同样值得留意的是,进入wait()时lk必须处于持锁状态,否则行为未定义。实际开发中我见过有人把unique_lock构造好之后先手动unlock(),再传给wait(),结果程序一会儿崩溃一会儿死锁,查了半天才发现基础用法都没搞对。

3. 一个能直接落地的生产者-消费者队列实战

3.1 完整的 C++11 代码示例

条件变量最经典的落地场景就是实现线程安全的任务队列。我直接给一份我在项目里用过的简化版,去掉业务无关的细节,核心结构保留完整:

#include <condition_variable> #include <deque> #include <mutex> #include <thread> #include <vector> #include <cstdio> class TaskQueue { public: void push(int v) { { std::lock_guard<std::mutex> lk(mtx_); deque_.push_back(v); } cv_.notify_one(); } // 阻塞弹出,直到拿到一个任务 int pop() { std::unique_lock<std::mutex> lk(mtx_); cv_.wait(lk, [this] { return !deque_.empty(); }); int v = deque_.front(); deque_.pop_front(); return v; } // 非阻塞尝试,拿不到立刻返回 false bool try_pop(int& out) { std::lock_guard<std::mutex> lk(mtx_); if (deque_.empty()) return false; out = deque_.front(); deque_.pop_front(); return true; } private: std::mutex mtx_; std::condition_variable cv_; std::deque<int> deque_; }; int main() { TaskQueue queue; // 消费者线程 std::thread consumer([&queue] { for (int i = 0; i < 100; ++i) { int v = queue.pop(); std::printf("consume %d\n", v); } }); // 生产者线程 std::thread producer([&queue] { for (int i = 0; i < 100; ++i) { queue.push(i); } }); producer.join(); consumer.join(); return 0; }

如果你把这份代码跑起来,会看到两台线程像“接力”一样配合:生产者每放一个数,消费者就立刻醒来拿走一个。任务从 push 到 pop 之间的延迟通常只有几十到几百微秒,而且消费者线程在无任务时是真正挂起的,不占 CPU。

3.2 为什么这段代码只在锁内改共享数据、在锁外通知

你可以看到我的push()里,锁的作用范围极小,只包住deque_的修改,然后把notify_one()放在锁外。这不是随手写出来的风格,而是经过几轮线上问题之后固化的习惯。

数据一致性由互斥锁保证:所有对deque_的读写都在锁内完成,不会出现两个线程同时操作、破坏容器结构的情况。通知只负责“叫醒”,并不承担任何数据传递。这里最忌讳的写法是不加锁直接push_back,然后靠 notify 让消费者去读——那会把共享数据直接暴露在数据竞争之下,轻则读到脏值,重则直接崩。

在这个设计里,pop()的wait谓词写的是!deque_.empty()。这行代码的作用不光是“等队列非空”,更重要的是:即使队列之前是空的,一旦被唤醒,它还会再确认一次“此刻确实非空”。这一步就是前面说的谓词重查,是防止虚假唤醒和防止丢失通知的保险丝。

3.3 有界队列:当生产者也需要等消费者时

上面的无界队列只等消费者,生产者永远能直接 push。但实际项目里队列长度往往要限流,比如“最多缓存 64 个任务”,否则内存会被无限撑大。这时候不只是消费者要等,生产者也要等“队列有空位”。

一个简单可靠的做法是维护两个条件变量:一个表示“队列非空”,给消费者用;一个表示“队列未满”,给生产者用。

class BoundedQueue { public: void push(int v) { std::unique_lock<std::mutex> lk(mtx_); not_full_.wait(lk, [this] { return deque_.size() < capacity_ || closed_; }); if (closed_) throw std::runtime_error("queue closed"); deque_.push_back(v); not_empty_.notify_one(); } int pop() { std::unique_lock<std::mutex> lk(mtx_); not_empty_.wait(lk, [this] { return !deque_.empty() || closed_; }); if (closed_ && deque_.empty()) throw std::runtime_error("queue closed"); int v = deque_.front(); deque_.pop_front(); if (deque_.size() < capacity_) { not_full_.notify_one(); } return v; } void close() { std::lock_guard<std::mutex> lk(mtx_); closed_ = true; not_empty_.notify_all(); not_full_.notify_all(); } private: std::mutex mtx_; std::condition_variable not_empty_; std::condition_variable not_full_; std::deque<int> deque_; std::size_t capacity_ = 64; bool closed_ = false; };

这段代码里,close()的作用十分重要:当业务系统开始收尾,得告诉所有等待中的线程“不会再来了”,否则消费者会永远卡在pop()里等一个永远不会出现的任务。关闭时把closed_置位并notify_all(),消费者看到队列空且已经关闭,就能正常退出而不是死等。这也是条件变量场景里最容易漏的一步:只想到“唤醒干活”,没想到“醒过来发现活已经没有了,但程序不能卡死”。

4. 虚假唤醒与谓词重查:为什么 while 是命根子

4.1 什么是虚假唤醒,它到底是不是“虚假宣传”

网上关于“虚假唤醒”的解释经常给人一种玄而又玄的感觉,好像操作系统有个 bug 似的。真实情况是,在各种线程阻塞原语的实现里,一个线程在没有收到显式通知的情况下,也可能从等待状态返回,这是标准明确允许的。它在 Linux/POSIX 世界里被称为 spurious wakeup。

为什么标准层允许这件事?一个重要原因是:底层实现无法在每一个平台都做到“绝对精确地只在收到通知时才返回”。关于这一点有个很出名的说法:即使你手动把所有信号都处理掉,某些实现出于历史和架构原因,也仍然可能提前唤醒。与其承诺一个难以保证的强语义,标准干脆把责任交给用户:醒来后必须重新检查条件。

也就是说,虚假唤醒不是“正常使用时极少发生、可以忽略”的边角料,而是条件变量设计的一部分。你应当把它当成一种必然可能的情况来写代码。

4.2 用 if 还是 while?一个真实的翻车例子

最经典的错法是这样的:

// 错误示范:只检查一次 int pop() { std::unique_lock<std::mutex> lk(mtx_); if (deque_.empty()) { cv_.wait(lk); // 被虚假唤醒或丢失通知后,直接往下走 } int v = deque_.front(); // 队列实际是空的!未定义行为 deque_.pop_front(); return v; }

表面上看起来,队列空就等,不空就拿。但虚假唤醒之后,线程从wait()返回,并不会再次检查deque_是否真的非空。如果此刻队列里确实没有数据,那么deque_.front()就是在空容器上做操作。行为未定义,多数时候是读到垃圾值或者程序崩溃。

而使用谓词重载后,内部天然就是 while 循环,不存在这个问题:

cv_.wait(lk, [this] { return !deque_.empty(); }); // 能执行到这里,说明此刻队列一定是非空的

标准里带谓词的wait等价于:

while (!pred()) { cv_.wait(lk); }

所以,下次如果你看到一个老开发直接写裸的cv_.wait(lk),你可以温和地提醒他:补一个谓词重载,或者至少在外层写 while。这不是风格差异,而是正确性问题。

4.3 谓词重查的价值不止于虚假唤醒

谓词重查还有个更实际的作用:解决“通知丢失,但条件其实已经满足”的场景。想象一下消费者调用wait之前,已经检查了一次谓词,发现为假,然后在另一个线程 push 并 notify 之后,消费者才真正进入wait。这不就出现丢失唤醒了吗?其实不会。因为condvar.wait(pred)在进入挂起前,实现会重新执行谓词检查;如果 push 已经发生,谓词已经为真,那么这个wait会立刻返回,线程直接处理任务,根本不会真睡过去。

核心原因在于,条件变量的“检查 + 挂起”是在持有互斥锁的前提下做的。只要所有的状态修改都在同一把锁下完成,那么“检查到条件不满足”和“真正进入睡眠”之间,没有任何缝隙可以让别的线程改变条件。这就是为什么条件变量不能脱离互斥锁使用。很多丢失唤醒的线上事故,最后查来查去,几乎都是某处用了独立的原子标志位或者第二把锁来保护状态,破坏了统一加锁的约束。

4.4 一个我反复提倡的防御性写法

如果你要维护的是一个老项目,代码里到处是裸wait(),很难一次性全改,那我建议你用一行注释兜底:“wait 返回后必须重新检查条件”。同时新代码一律采用下面的模板:

std::unique_lock<std::mutex> lk(mtx_); cv_.wait(lk, [this] { return condition_is_true(); }); // 到这里,condition_is_true() 一定为 true

这样做的好处是,任何读你代码的人都能一眼看出你一定做了重查,不会再担心“唤醒后空指针”“醒来后发现状态过期”这类问题。

5. wait_for / wait_until 的超时语义与实战踩坑

5.1 三个版本怎么选

前面一直在讲“无限期地等”,但真实业务里,我们经常需要“最多等多久”。C++11 提供了两种带超时的方法:

template <class Rep, class Period> std::cv_status wait_for(std::unique_lock<std::mutex>& lock, const std::chrono::duration<Rep, Period>& rel_time); template <class Rep, class Period, class Predicate> bool wait_for(std::unique_lock<std::mutex>& lock, const std::chrono::duration<Rep, Period>& rel_time, Predicate pred);

不带谓词的版本返回std::cv_status::timeout或std::cv_status::no_timeout。带谓词的版本更常用:如果超时到了但谓词仍为假,返回false;如果在超时之前或恰好超时时谓词已经为真,返回true。

注意一个重要细节:带谓词的wait_for返回false,是一件非常干净的事。它意味着“我确实没等到条件满足”。比如一个消费者最多等 3 秒,如果 3 秒后队列仍为空,代码想去干点别的事情,那么写:

bool ok = cv_.wait_for(lk, std::chrono::seconds(3), [this] { return !deque_.empty(); }); if (!ok) { // 超时且条件不满足 return false; }

比自己去判断返回状态要直观得多。

5.2 wait_for 返回后还需要再检查谓词吗

调用带谓词的wait_for,它在内部就是对谓词循环重查的封装,所以返回true可以放心认为条件成立,返回false则代表条件不成立。但如果你用不带谓词的裸版本,那么不管是 timeout 还是 no_timeout,返回值都不能说明队列状态,你还得自己加锁再看一眼队列。这也是我在项目里规定“业务代码禁止使用裸wait_for”的原因。

这里我再补一个容易踩的坑:如果你自己写一个while循环,反复调用裸wait_for,请注意每次调用的超时时间是独立的“每次重新计时”,总超时时间会变成若干次超时之和。想要“总共最多等 3 秒”,必须自行计算剩余时间,再传给wait_for。代码模板如下:

int pop_with_timeout(int& v, std::chrono::milliseconds timeout) { std::unique_lock<std::mutex> lk(mtx_); auto deadline = std::chrono::steady_clock::now() + timeout; while (!deque_.empty() == false) { auto now = std::chrono::steady_clock::now(); if (now >= deadline) { return -1; // 超时 } auto remain = std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now); cv_.wait_for(lk, remain); } v = deque_.front(); deque_.pop_front(); return 0; }

用steady_clock而不是system_clock,因为系统时钟可能被用户或 NTP 调整,而steady_clock单调递增,只用来测时间间隔,正合适。

5.3 短超时带来的性能和精度问题

有些同事喜欢把wait_for写成wait_for(lk, std::chrono::milliseconds(1)),觉得“这样响应快”。实际上,wait_for的超时依赖操作系统的定时器精度,并非你写 1 毫秒它就精确 1 毫秒。在常见的 Linux 调度器上,超时精度可能是几个毫秒到十几个毫秒的量级,短于这个量级的超时,往往只会增加无谓的系统调用和上下文切换。

与其把超时设得很短然后靠轮询,不如把逻辑改成长一些的超时配合一次健全的退出机制。比如任务是“最多等 100 毫秒,没等到就去检查退出标志”,那么用一个 100 毫秒的wait_for就够了,不要拆成 1 毫秒的循环去轮询。条件变量最大的优势是“有信号就立刻醒”,这个优势在短超时轮询里会被完全抹掉,退化成自旋。

5.4 shutdown 场景下的 wait_for 用法

真实的优雅关闭往往需要给消费者一个“最多等你多久”的缓冲。比如推出一个后台工作线程,我要等它把当前任务处理完,但最多等 2 秒,超时就直接终止。这里我会把停止标志放进谓词:

// 消费者线程内部 std::unique_lock<std::mutex> lk(mtx_); bool done = cv_.wait_for(lk, std::chrono::seconds(2), [this] { return shutdown_ || !deque_.empty(); }); if (!done) { // 超时,不管了,退出 break; } if (shutdown_ && deque_.empty()) { // 已经要求停止,队列也空了 break; }

这个模式既保证了“线程最多拖 2 秒”,又不会在任务确实还在时提前退出。很多项目把关闭标志和条件变量混用,结果要么永远不会唤醒,要么关掉得特别暴力,就是因为漏了shutdown_这个谓词分支。

6. 线上遇过的几个“灵异现象”与排查路径

6.1 现象一:一切正常,就是偶尔丢任务

最早我负责一个任务分发模块,线程 A 定时往队列里塞任务,线程 B 负责消费。上线后偶尔会发现任务丢了,一开始怀疑是生产者漏发,查了半天,最后抓包分析日志才发现:消费线程是被唤醒过,但由于事件窗口太短,它醒来看队列发现“空”,于是继续睡,而生产者实际上已经把任务放进去了,只是还没来得及执行notify_one(),或者说通知已经发生,但消费者那边当时正在检查旧状态。

这个问题的根源就是谓词重查不够严谨。正确做法是生产者在锁内 push,锁外 notify;消费者在锁内用带谓词的wait,谓词直接读队列。两把锁都是同一把锁,那么“push 后 notify”与“wait 前检查谓词”两个动作就能严格串行化,丢失任务这种事就从架构上被杜绝了。

如果你在排查“灵异丢任务”,先别急着怀疑硬件或编译器优化,第一件事就是审查所有对共享队列的访问是否都在同一把锁下;第二件事是审查有没有裸wait之后没有 while 重查。90% 的丢失唤醒都出在这两处。

6.2 现象二:通知了,但线程迟迟不醒

还有一个高频问题:明明生产者调用notify_one()了,消费者却像睡死了一样,延迟几百毫秒甚至几秒才处理。排查到最后,发现生产者在某个共享变量的操作上持锁时间过长,比如在锁内做了网络 I/O、写日志、或者一个超过 100 毫秒的计算。

条件变量本身不保证“通知即立刻调度”,唤醒的操作往往要等当前持锁线程释放锁,消费者才能抢到锁。你要是把一把锁攥在手里 300 毫秒,那消费者就算醒了也在等锁。

解决思路有三条:

  • 缩小临界区:把 I/O、计算、日志从锁内挪到锁外;
  • 用多把细粒度锁,不要让一个全局锁卡住所有消费者;
  • 实在挪不开再考虑无锁队列等更复杂的方案,但通常加锁优化已经够解决 95% 的问题。

另外也别忽略另一种可能:消费者线程优先级被调低了,导致可运行但迟迟轮不上 CPU。这属于操作系统的调度范畴,跟条件变量本身无关,别调半天代码白费劲。

6.3 现象三:两个条件变量互相等,程序卡死

有界队列场景里,我们用了not_full_和not_empty_两个条件变量。如果多个生产者和多个消费者同时参与,很容易有人在锁内调用wait,别人push之后再notify,但 notify 选择的等待者刚好是生产者,只会让生产者继续等“空位”,而真正需要被唤醒的消费者反而没醒。严重的时候就演变成“生产者等空位,消费者等任务,谁都等不到谁”。

最土但有效的排查法是打印所有线程的状态,看它们分别在哪个wait上挂起。只要发现“所有线程都挂在不同条件的 wait 上”,基本就能断定发生了条件变量间的死锁。解决这类问题的关键不是多几次 notify,而是尽量避免在持锁的情况下循环等待多个条件,尤其要保证每次状态变化时,把可能受影响的所有等待者都notify_all一下。宁可多叫醒几个无效线程,也不要让它彻底卡死。

6.4 现象四:程序退出时崩溃

还有一类“灵异”发生在程序退出阶段:主线程把队列对象析构了,但消费者线程还挂在wait上,然后 wait 访问了已经析构的成员变量,直接段错误。标准要求:std::condition_variable析构时,不能有线程还在等待它,否则行为未定义。

规避手段很简单:退出前,先置closed_,再notify_all(),最后join()掉所有线程,确保等待者都醒过来且退出了,再让队列对象析构。如果你用std::thread管理线程,析构时一定要等等所有消费者都安全退出;别在主线程销毁队列后,后台线程还在用。这里没有捷径,就是代码顺序的问题。

我通常会写一个close()方法,内部做三件事:mutex.lock()改标志、notify_all()、再解锁;然后主流程join所有线程后进行对象析构。这样能把所有线程的生命周期和队列的生命周期严格对齐。

6.5 我对条件变量使用的最终检查清单

吃过的亏多了之后,我给自己定了一条“上代码评审前必须过的 checklist”:

  1. 所有共享状态是否都在同一把锁的保护下修改?
  2. wait是否一律使用带谓词的重载,保证唤醒后重查条件?
  3. notify是否放在锁外,避免无意义的锁竞争?
  4. 通知选择notify_one还是notify_all是否经过推演?
  5. 程序退出前,是否已经置停止标志、notify_all并把所有等待线程join掉?
  6. 超时场景用的是steady_clock,并且重新计算剩余时间,而不是循环里反复设置固定超时?

只要这六条都能给出肯定答案,条件变量这块基本就不会出让人头痛的线上事故了。说白了,条件变量本身不复杂,复杂的是线程交互过程中那些看不见的时间窗口;而所有正确的并发编程,本质上就是把这些窗口一个个填平,让不确定变成确定。

最后再分享一个我在实战里养成的习惯:每次写条件变量代码,我都会单独写一个几分钟就能跑完的压测小程序,起几十个线程,反复 push-pop 跑几十万次,同时开着线程调度跟踪。大部分潜伏的问题,在压测里都会很快现出原形。线上代码改完一定要过这一关,别拿生产环境当测试环境。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询