资讯动态

C++中std::condition_variable 条件变量的使用

发布时间:2026/10/20 3:25:31 来源:尧图企业网站定制
前言多个线程共享一个队列时消费者如果不停轮询「有没有新数据」会把 CPU 白白烧在循环上睡一个固定时长再查又会在数据到达时反应迟钝。std::condition_variable解决的正是这个问题它让线程在条件不满足时真正睡眠并在另一个线程修改共享状态后把你唤醒。关于它最常见的三个误解是第一以为notify_one()之后等待线程会「立刻」运行第二以为wait()可以不带谓词醒来就说明条件成立第三以为notify必须在持锁时调用。这三点都不准确后面会逐一说明。本文从条件变量与互斥量的配合机制讲起重点解释wait(lock, pred)的等价展开、虚假唤醒spurious wakeup为什么必须防、通知应该放在锁内还是锁外最后给出一个可直接改用的有界阻塞队列并列出七个高频坑点。一、条件变量、互斥量与共享状态的三方关系条件变量本身不保存任何「条件」。它只是一个等待队列线程调用wait时把自己挂到这个队列上另一个线程调用notify_one/notify_all时从队列里取出一个或全部线程去唤醒。真正的条件是你自己用互斥量保护的那份共享数据比如队列非空、缓冲区未满、任务已完成。因此一个正确的等待必须同时具备三样东西组成作用典型类型互斥量保护共享状态避免数据竞争std::mutex条件变量让线程睡眠与唤醒std::condition_variable谓词判断条件是否成立lambda 或函数对象std::condition_variable只能搭配std::unique_lockstd::mutex使用这是标准规定的接口形态std::condition_variable_any则可以搭配任意满足 BasicLockable 的锁类型代价通常是内部多一层开销。日常场景优先用前者。wait的关键语义是「原子地解锁并阻塞」它先把锁释放让别的线程能进入临界区修改状态然后把当前线程挂起被唤醒后它在返回之前重新获取同一把锁。也就是说wait返回时你一定是持锁的可以安全地读共享状态。这个「解锁—睡眠—重新加锁」是一个整体不会出现「已经决定要睡但还没解锁」的窗口否则通知就会丢失。二、wait 的三种形式与虚假唤醒std::condition_variable提供的等待接口可以归成三类wait(lock)一直等到被通知wait(lock, pred)带谓词等价于while (!pred()) wait(lock);wait_for/wait_until带超时同样有带谓词的重载。带谓词的重载不是语法糖那么简单它是正确性的保证。原因有两个第一虚假唤醒。标准允许wait在没有对应通知的情况下返回这不是某个编译器的 bug而是底层实现如 futex、条件变量原语允许的行为。没有谓词循环你就会在条件根本不成立时继续往下执行。第二通知与抢锁之间的竞争。即使通知确实来自正确的线程在你被唤醒到重新拿到锁之间可能有第三个线程先拿到锁把状态又改回去了。谓词循环会让你重新检查不成立就继续睡。所以手写while (!pred()) cv.wait(lock);与cv.wait(lock, pred)语义等价后者更简洁。谓词是在持有锁的状态下求值的这一点很重要谓词里读共享数据不会产生数据竞争。一个容易忽略的细节是谓词应该只读状态、不要做耗时操作也不要调用可能再次阻塞的函数。它在每次唤醒后都会执行写得太重会拖慢整个临界区。三、完整示例有界阻塞队列下面的例子把上述规则落成一个模板类。它有两个条件变量分别对应「非空」和「未满」避免生产者和消费者互相唤醒错对象。// 适用于 C17#include condition_variable#include cstddef#include deque#include mutex#include stdexcept#include thread#include utilitytemplate class Tclass BlockingQueue {public:explicit BlockingQueue(std::size_t capacity) : cap_(capacity) {if (capacity 0) {throw std::invalid_argument(capacity must be greater than zero);}}// 入队队列已关闭时返回 false调用方可据此结束生产bool push(T value) {std::unique_lockstd::mutex lk(m_);not_full_.wait(lk, [this] { return q_.size() cap_ || closed_; });if (closed_) {return false;}q_.push_back(std::move(value));lk.unlock();not_empty_.notify_one();return true;}// 出队队列关闭且已排空时返回 falsebool pop(T out) {std::unique_lockstd::mutex lk(m_);not_empty_.wait(lk, [this] { return !q_.empty() || closed_; });if (q_.empty()) {return false;}out std::move(q_.front());q_.pop_front();lk.unlock();not_full_.notify_one();return true;}void close() {{std::lock_guardstd::mutex lk(m_);closed_ true;}not_empty_.notify_all();not_full_.notify_all();}private:std::size_t cap_;std::dequeT q_;bool closed_ false;std::mutex m_;std::condition_variable not_empty_;std::condition_variable not_full_;};int main() {BlockingQueueint q(4);std::thread producer([q] {for (int i 0; i 20; i) {if (!q.push(i)) {break;}}q.close();});std::thread consumer([q] {int value 0;while (q.pop(value)) {// 这里处理 value}});producer.join();consumer.join();return 0;}几点设计说明push里先wait到「未满或已关闭」再判断closed_。关闭后不再接受新元素唤醒所有阻塞的生产者让它们退出。pop在「非空或已关闭」时醒来若队列为空说明已经排空返回false。两个notify_one都放在lk.unlock()之后。这不是必须的但能避免被唤醒的线程立刻又因为抢不到锁而阻塞减少一次上下文切换。close()修改closed_时持锁通知时已经离开临界区随后用notify_all唤醒所有等待者。只唤醒一个是不够的因为可能同时有多个生产者和消费者在等。四、通知时机与丢失唤醒「丢失唤醒」是初学者最常踩的坑线程 A 检查条件为假正准备调用wait线程 B 此时修改状态并调用notify然后 A 才真正进入等待于是永远睡下去。这个问题的根源不在条件变量而在于检查与等待没有和通知互斥。标准给出的解法就是「持锁检查 在锁内进入等待」只要 A 在检查条件时持有互斥量B 修改状态也必须持同一把锁那么 B 的修改要么发生在 A 检查之前A 会看到新状态根本不睡要么发生在 A 已经原子地解锁并挂起之后通知能送达。不存在中间窗口。由此可以推出notify的位置规则修改共享状态必须持锁notify_one/notify_all本身不要求持锁放在解锁之后通常更好如果放在锁内正确性没问题只是可能让被唤醒的线程先阻塞在锁上多一次调度。还有一个相关问题notify_one只唤醒一个等待者。如果同一个条件变量上等待的是不同的条件比如同时等「队列非空」和「任务取消」被唤醒的那个可能发现自己的谓词仍为假继续睡而真正该醒的线程没被叫到。解决办法是给不同条件用不同的条件变量或者改用notify_all。前者更精确后者更简单但会带来惊群。五、与原子变量、信号量的分工有人会问既然std::atomicbool也能做标志为什么还要条件变量区别在于阻塞。原子变量适合无等待的快速检查但拿不到锁或条件不满足时你仍然需要某种方式让出 CPU。自旋等待在低竞争、临界区极短时可能有效但在等待时间不可预测时会持续占用核心竞争激烈时反而更慢。条件变量适合「等待时间可能很长、由事件驱动」的场景std::counting_semaphore需要 C20适合计数型资源std::future/std::promise适合一次性结果传递。它们不是互相替代的关系而是不同粒度的工具。另外要强调volatile不是同步手段它不建立线程间的 happens-before 关系用volatile bool做「停止标志」在多线程下依然是数据竞争属于未定义行为undefined behavior, UB。常见坑点❌ 不带谓词地等待醒来就认为条件成立cpp cv.wait(lock); if (!queue.empty()) { /* ... */ }✅ 用谓词重载让检查与等待绑定cpp cv.wait(lock, [] { return !queue.empty() || stopped; });❌ 在未持有锁的情况下调用waitcpp std::unique_lockstd::mutex lk(m, std::defer_lock); cv.wait(lk); // 前置条件不满足✅wait前必须让lk处于持有状态或者直接用std::lock_guard之外的unique_lock 正常构造。❌ 修改共享状态时不持锁只在中途加锁发通知cpp ready true; // 数据竞争 { std::lock_guardstd::mutex lk(m); } cv.notify_one();✅ 状态的写入与读取都要在同一把锁保护下cpp { std::lock_guardstd::mutex lk(m); ready true; } cv.notify_one();❌ 用if而不是while包裹waitcpp if (!ready) cv.wait(lock);✅ 虚假唤醒会让if失效应写成while 或直接用谓词重载。❌ 用一个条件变量等待多个互不相同的条件且只用notify_onecpp cv.wait(lk, [] { return has_data || canceled; });✅ 拆成两个条件变量分别通知或者在该场景使用notify_all。❌ 析构时仍有线程阻塞在条件变量上cpp ~Queue() default; // 等待线程仍挂在 cv 上✅ 析构前先置停止标志并notify_all再join 所有线程对象生命周期必须覆盖等待线程。❌ 把wait_for的返回值丢掉把超时当成条件成立cpp cv.wait_for(lk, 100ms, pred); use(data); // 可能 predicate 仍为假 ✅ 检查返回值或重新求值谓词后再使用数据。❌ 在谓词里做阻塞或加锁操作cpp cv.wait(lk, [] { return fetch_from_network(); }); ✅ 谓词只读已经受保护的本地状态耗时工作放到临界区之外。总结要点正确做法常见错误等待形式wait(lock, pred)裸wait(lock)条件检查持锁、循环检查if判断一次状态修改持互斥量无锁写共享变量通知位置解锁后通知更优误以为必须锁内唤醒对象按条件拆分 cv一个 cv 等多种条件退出机制停止标志 notify_alljoin直接析构同步手段mutex cv / atomicvolatile标志条件变量的难点不在 API而在「谁保护什么、什么时候通知」这套约定。把互斥量、谓词、通知三者的顺序固定下来绝大多数偶发的挂起和丢数据问题都会消失。牢记wait返回时一定持锁、谓词一定在锁内求值剩下的就是按业务拆分条件变量。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑