condition_variable 生产者消费者完整实现:虚假唤醒与丢失唤醒一次讲透
发布时间:2026/10/1 10:33:02来源:尧图网络
std::condition_variable解决的是一个很具体的需求线程要等到某个条件成立才继续干活。最笨的办法是轮询但轮询要么烧 CPU、要么靠sleep猜时间条件变量让等待方真正睡着由改状态的一方负责叫醒它。代价是它把「状态」和「通知」拆成了两步于是就有了虚假唤醒spurious wakeup和丢失唤醒lost wakeup这两个几乎人人踩过的坑。这篇把它们一次讲透并用一个能直接拿去用的有界阻塞队列收尾。1. 引子轮询为什么不行假设消费者要等队列里有数据// 反例①②不要这么写// ① 忙等100% 占一个核什么都没干while(queue.empty()){}// ② 定时睡眠睡短了白烧 CPU睡长了吞吐掉下来 —— 没有正确答案while(queue.empty()){std::this_thread::sleep_for(std::chrono::milliseconds(10));}两种写法都错在同一个地方等待方并不知道「什么时候该醒」。条件变量的思路是把这件事反过来 —— 改状态的人最清楚条件什么时候变成立那就由它来通知方案CPU 占用延迟正确性忙等while (empty()) {}满载一个核心立刻正确但不可用sleep_for轮询低最坏等于一个睡眠周期正确但延迟不可控条件变量cv.wait睡眠时为零通知后立刻唤醒正确且高效首选官方文档std::condition_variable — cppreference · std::condition_variable::wait — cppreference2. 为什么wait必须配unique_lock这是初学时最想不通的一条限制wait明明只是等为什么不能用更轻的lock_guard因为wait在内部做了四件事其中两件需要主动解锁和重新加锁cv.wait(lock) 内部做的四件事 ┌──────────────────────────────────────────────────────────────┐ │ ① 解锁 lock 关联的互斥量并把自己挂到 cv 的等待队列上 │ │ ↑ 这一步必须是原子的先挂队列后解锁会丢通知 │ │ 先解锁后挂队列会丢唤醒 │ │ ② 睡眠不占 CPU │ │ ③ 被唤醒后重新加锁 —— 阻塞在这里直到拿到锁 │ │ ④ 返回此时调用方一定持有锁可以安全检查共享状态 │ └──────────────────────────────────────────────────────────────┘ lock_guard只有构造加锁 / 析构解锁中间无法解锁 → 干不了 ①③ unique_lock提供 lock() / unlock() 且可移动 → wait 需要的接口它都有// 这段编译不过 —— 反例不要这么写// 编译错误no matching function for call to// condition_variable::wait(std::lock_guardstd::mutex)std::mutex m;std::condition_variable cv;std::lock_guardstd::mutexlock(m);cv.wait(lock);// lock_guard 没有 unlock()wait 没法在等待期间放开互斥量unique_lock的额外能力就两条可以手动unlock/lock并且可以移动。前者是wait的硬需求后者是「把锁传出函数」的硬需求。守卫可手动解锁可移动能配condition_variable开销lock_guard否否不能最小一个指针unique_lock是是能略大指针 owns_lock标志scoped_lock否否不能最小3. 虚假唤醒为什么必须用带谓词的wait条件变量有一个反直觉的规定wait可能在没有收到任何notify的情况下返回这叫虚假唤醒spurious wakeup。它是由底层实现pthread_cond_wait允许的事传导上来的行为标准明确允许你无法关掉。所以裸wait永远是错的必须写成带谓词的形式// 谓词版 wait 的等价展开片段不是完整程序std::unique_lockstd::mutexlock(mutex);while(!pred()){// ① 先检查条件已经成立就直接往下走不睡cv.wait(lock);// ② 睡之前原子地解锁醒后重新加锁}// ③ 退出循环时条件一定成立而且手里握着锁标准库把这个循环封装成了重载cv.wait(lock, pred)——它的语义就是while (!pred()) cv.wait(lock);两行代码完全等价。谓词在这里干了三件事挡住虚假唤醒醒过来发现条件其实不成立就接着睡挡住「被别人的通知误唤醒」notify_all唤醒所有人但只有条件真正成立的那个该干活顺手消掉丢失唤醒下一节详述。cv.wait(lock, pred) 的时序 —— 生产者先写数据再改标志再通知 生产者 消费者 │ │ │ unique_lock lk(m); │ pred() 为假 → 准备睡 │ │ │ ┌─────────────────────┤ ① 原子地「挂队列 解锁」 │ │ 消费者已进入等待 │ │ └─────────────────────┤ │ lock_guard lk(m); │ ▓▓ 睡眠中不占 CPU ▓▓ │ data 42; ← ① 先改共享状态 │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ 离开作用域解锁 ← ② 释放锁 │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ cv.notify_one(); ← ③ 再通知锁外 │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │ │ ┌─────────────────────┤ ② 被唤醒重新加锁 │ │ │ ③ 再查 pred() → 成立 │ └─────────────────────┤ ④ 读 data看到 42 │ │ release/acquire 保证可见性先看一个最小可运行版本顺便把wait_for的超时路径也跑一遍// cv_minimal.cpp — 编译: g -stdc17 -Wall -O2 -pthread cv_minimal.cpp -o cvm#includechrono#includecondition_variable#includecstdio#includemutex#includethreadintmain(){constexprintkResult42;std::mutex mutex;std::condition_variable cv;boolreadyfalse;intpayload0;std::threadworker([]{std::unique_lockstd::mutexlock(mutex);cv.wait(lock,[ready]{returnready;});// 带谓词必须这么写std::printf(worker 被唤醒payload %d\n,payload);});{std::lock_guardstd::mutexlock(mutex);payloadkResult;readytrue;// ① 先改状态锁内}cv.notify_one();// ② 再通知锁外worker.join();std::mutex timeout_mutex;std::condition_variable timeout_cv;std::unique_lockstd::mutexlock(timeout_mutex);std::cv_status statusstd::cv_status::no_timeout;while(status!std::cv_status::timeout){// 裸 wait_for 也要自己套循环防虚假唤醒statustimeout_cv.wait_for(lock,std::chrono::milliseconds(20));}std::printf(wait_for 返回超时 %d\n,static_castint(statusstd::cv_status::timeout));}worker 被唤醒payload 42 wait_for 返回超时 1最后那段while (status ! std::cv_status::timeout)值得留意wait_for返回cv_status::timeout表示超时、cv_status::no_timeout表示被通知或虚假唤醒。因为它也可能虚假唤醒所以裸wait_for同样要套循环—— 这跟wait要套while (!pred())是同一个理由。4. 丢失唤醒通知打在了空气里丢失唤醒lost wakeup的本质是等待方「检查条件」和「真正进入等待」是两步中间有一个间隙。如果修改方恰好在这个间隙里改完状态并发出通知这次通知就会落空——等待方随后才进入等待就再也等不到任何通知了。丢失唤醒lost wakeup的产生条件按时间顺序 ───────────────────────────────────────────── [B] unique_lock lk(m); ← B 拿到锁 [B] if (!ready) ← 检查此刻 ready 还是 false进入分支 B 在「检查」和「wait」之间被切走 [A] ready true; ← A 改状态 [A] cv.notify_one(); ← A 发通知但 B 还没 wait → 通知落空 [B] cv.wait(lk); ← B 进入等待错过刚才的通知 → 永远等注意顺序B 的「检查」要发生在 A 的「改状态」之前而 B 的「进入等待」要发生在 A 的「通知」之后——只有这个交错成立通知才会落空。如果像常见笔误那样把 A 的ready true画在 B 检查之前那 B 检查时!ready已经是false根本不会进wait也就不存在丢失。它和「先改状态还是先加锁」纠缠在一起所以标准写法有两条铁律改共享状态的代码必须在同一把互斥量保护下等待方检查状态时也持有这把锁谓词要和状态检查用完全相同的锁保护这样「检查状态」和「进入等待」不会被切开。满足这两条之后带谓词的wait就把丢失唤醒也堵住了如果通知先到pred()在第一次检查时就已经是truewait直接返回根本不会睡。下面这段故意让通知发生在等待之前// cv_lost_wakeup.cpp — 编译: g -stdc17 -Wall -O2 -pthread cv_lost_wakeup.cpp -o cvlw#includechrono#includecondition_variable#includecstdio#includemutexintmain(){std::mutex mutex;std::condition_variable cv;boolreadyfalse;{std::lock_guardstd::mutexlock(mutex);readytrue;// ① 先改状态}cv.notify_one();// ② 此刻没有任何线程在 wait这次通知直接丢了std::unique_lockstd::mutexlock(mutex);constboolokcv.wait_for(lock,std::chrono::milliseconds(200),[ready]{returnready;});// 带谓词 → 立刻返回不等满 200msstd::printf(带谓词 wait_for 立刻返回 %d\n,static_castint(ok));}带谓词 wait_for 立刻返回 1如果把谓词去掉、写成裸cv.wait_for(lock, 200ms)这里就要白白等满 200 毫秒等价于一次丢失唤醒拿到cv_status::timeout之后再自己检查ready。同样的代码裸wait_for实测输出等了 200 ms, timeout1带谓词则是等了 0 ms, ok1——谓词把这 200 毫秒和那段错误的等待逻辑一起消掉了。官方文档std::condition_variable::wait_for — cppreference写了「虚假唤醒导致返回」是允许的· C Core Guidelines CP.42wait必须带谓词不要用裸wait5.notify_one还是notify_all选错的后果不一定是崩溃更常见的是偶发卡死或不必要的唤醒风暴场景选哪个原因一个元素入队只可能有一个消费者拿到它notify_one唤醒一个就够其余线程白醒一次又睡回去惊群 thundering herd条件变化让所有等待者都能前进如closed、start、配置整体切换notify_all只叫一个会漏掉其他真正能干活的人一个cv上等不同谓词的线程如既等队列非空、又等关闭统一用notify_all被唤醒的那个未必是条件成立的那个生产者与消费者共用一把cv别这么写用两个cv生产者的通知可能被另一个生产者吃掉 —— 典型的「通知丢失」变种最后一行是最容易忽略的生产者的通知应该发给消费者。如果两类角色共用一个条件变量notify_one有可能叫醒另一个生产者而被叫醒的人一看「队列还是满的」立刻又睡回去 —— 真正的消费者一个都没醒整个系统就此僵住。所以下面队列用了not_empty_和not_full_两个条件变量。notify_all的语义可以直接跑出来看// cv_notify_all.cpp — 编译: g -stdc17 -Wall -O2 -pthread cv_notify_all.cpp -o cvna#includeatomic#includecondition_variable#includecstdio#includemutex#includethread#includevectorintmain(){constexprintkWorkers5;std::mutex mutex;std::condition_variable cv;boolstartfalse;std::atomicintwoke{0};std::vectorstd::threadworkers;for(inti0;ikWorkers;i){workers.emplace_back([]{std::unique_lockstd::mutexlock(mutex);cv.wait(lock,[start]{returnstart;});// 谓词版错过通知也不会白等woke.fetch_add(1,std::memory_order_relaxed);});}{std::lock_guardstd::mutexlock(mutex);starttrue;}cv.notify_all();// 一次唤醒全部等待者for(autot:workers)t.join();std::printf(notify_all 后醒来的线程数 %d / %d\n,woke.load(),kWorkers);}notify_all 后醒来的线程数 5 / 5把notify_all()换成notify_one()输出里的线程数就会变成一个不确定的小于 5 的数剩下 4 个永远等下去程序只能靠join卡住暴露问题—— 这就是「该用 all 却用了 one」的后果。这里之所以能打印出确定值是因为谓词版wait即使错过通知也会在第一次检查时发现start已经是true。6. 完整示例有界阻塞队列把上面的规则合起来写一个多生产者多消费者的有界阻塞队列bounded blocking queue——生产者在队满时等待、消费者在队空时等待、close()之后双方都能干净退出// bounded_queue.cpp — 编译: g -stdc17 -Wall -O2 -pthread bounded_queue.cpp -o bq#includeatomic#includecondition_variable#includecstdio#includedeque#includemutex#includethread#includevectorclassBoundedQueue{public:explicitBoundedQueue(std::size_t capacity):capacity_(capacity){}voidpush(intvalue){std::unique_lockstd::mutexlock(mutex_);not_full_.wait(lock,[this]{returnqueue_.size()capacity_||closed_;});if(closed_)return;// 已关闭不再接受新数据queue_.push_back(value);not_empty_.notify_one();// 只叫一个消费者}boolpop(intout){std::unique_lockstd::mutexlock(mutex_);not_empty_.wait(lock,[this]{return!queue_.empty()||closed_;});if(queue_.empty())returnfalse;// 已关闭且排空 → 消费者可以退出了outqueue_.front();queue_.pop_front();not_full_.notify_one();// 只叫一个生产者returntrue;}voidclose(){{std::lock_guardstd::mutexlock(mutex_);closed_true;}not_empty_.notify_all();// 条件整体变化叫醒所有人not_full_.notify_all();}boolempty()const{std::lock_guardstd::mutexlock(mutex_);returnqueue_.empty();}private:mutablestd::mutex mutex_;std::condition_variable not_empty_;// 生产者通知它消费者等它std::condition_variable not_full_;// 消费者通知它生产者等它std::dequeintqueue_;std::size_t capacity_;boolclosed_{false};};intmain(){constexprintkProducers4;constexprintkConsumers3;constexprintkPerProducer100;constexprintkCapacity8;BoundedQueuequeue(kCapacity);std::atomiclonglongtotal{0};std::atomicintconsumed{0};std::vectorstd::threadconsumers;for(intc0;ckConsumers;c){consumers.emplace_back([queue,total,consumed]{intvalue0;while(queue.pop(value)){// pop 返回 false 队列关闭且已排空total.fetch_add(value,std::memory_order_relaxed);consumed.fetch_add(1,std::memory_order_relaxed);}});}std::vectorstd::threadproducers;for(intp0;pkProducers;p){producers.emplace_back([queue,p]{for(inti0;ikPerProducer;i){queue.push(p*kPerProduceri1);// 每个生产者发自己的一段编号}});}for(autot:producers)t.join();// 等生产者全部结束queue.close();// 再关闭队列for(autot:consumers)t.join();// 消费者排空后自然退出std::printf(生产 %d 个消费 %d 个\n,kProducers*kPerProducer,consumed.load());std::printf(校验和 %lld\n,total.load());std::printf(队列最终为空 %d\n,static_castint(queue.empty()));}output 生产 400 个消费 400 个 校验和 80200 队列最终为空 1三个数字都是确定的和调度顺序无关400 个进、400 个出没有多消费也没有漏消费校验和 80200 12…400生产者按编号连续发号不管经过几个线程、什么顺序总和恒定队列最终为空说明消费者把最后一个元素也取走了。几个设计要点①两个条件变量生产者的notify_one不会误伤另一个生产者②close()单独成一个函数把「状态变更」和「通知」分开避免在持锁状态下通知③ 退出条件是「已关闭 队列为空」两个条件的合取只关队列不排空会丢数据只排空不关闭会让消费者永远等下去④empty()也拿了锁因为它在所有线程join之后才调用属于「顺手写对」而不是必需。官方文档std::condition_variable::notify_all — cppreference · std::unique_lock — cppreference · C Core Guidelines · 并发章节7. 延伸阅读std::condition_variable — cppreference —— 类总览wait/wait_for/wait_until/notify_*的完整重载列表std::condition_variable::wait — cppreference —— 谓词重载的等价形式while (!pred()) wait(lock);就在这里写明std::condition_variable_any — cppreference —— 能配任意 BasicLockable 的版本代价是内部多一层开销只在需要非std::mutex时用它std::unique_lock — cppreference ——lock/unlock/owns_lock与移动语义理解它为什么是condition_variable的唯一搭档C Core Guidelines · 并发章节 CP.42 —— 「wait必须带谓词」这条规则的官方出处8. 一句话总结condition_variable把「等条件成立」变成一次真正的睡眠等待方持unique_lock调用wait(lock, pred)语义等价于while (!pred()) wait(lock);wait在内部原子地释放锁并挂起、被唤醒后重新加锁谓词同时挡掉了虚假唤醒和丢失唤醒 —— 所以永远别写裸wait。通知策略上单个元素入队/出队用notify_one条件整体变化关闭、启动、配置切换用notify_all生产者和消费者必须各用一个条件变量否则通知会在同类线程之间空转整个队列就此死等。
网站建设高端定制企业官网