C++ 条件变量(condition_variable)

在多线程协作中,我们经常会遇到 “一个线程需要等待另一个线程把数据准备好才能继续执行” 的场景。如果让等待线程写一个死循环不停地去检查状态变量,就会白白消耗大量的 CPU 时间。

这就好比你在家等快递:每隔几秒跑到门口看一眼非常累人,更好的办法是先在屋里安心休息,等快递员到了按响门铃通知你,再去开门取件。

C++ 在 <condition_variable> 头文件中提供的 std::condition_variable(条件变量),就是专门用来实现 “条件未满足时睡眠等待、状态更新后按铃唤醒” 的同步工具,如下图所示。

轮询盲等与条件变量按铃唤醒机制对比

wait() 与 notify_one() 基本用法

在 C++ 中,std::condition_variable 不能单独工作,通常需要下面这 3 个部分配合使用:

  • 共享状态变量:比如 bool ready = false;,用来记录数据到底准备好了没有。
  • 互斥锁:比如 std::mutex,用来保护共享状态变量的读写安全。
  • 条件变量:即 std::condition_variable,用来让等待线程进入睡眠,或者按门铃叫醒它。

语法:

#include <condition_variable>

std::condition_variable cv;

// 等待线程
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []() { return 条件表达式; });

// 通知线程
cv.notify_one();

说明:

对于上面的等待与通知语法,我们需要重点搞清楚以下 3 个部分:

  • lock(第 1 个参数):我们必须传入已加锁的 std::unique_lock 对象,不能传入 std::lock_guard。因为线程准备睡眠时,wait() 需要先把锁临时解开让其他线程修改数据,醒来后再重新锁上,这只有 unique_lock 支持。
  • 放行条件(第 2 个参数):通常传入一个返回布尔值的 Lambda 表达式,表示 “等待直到该条件成立”。返回 true 说明条件已满足,直接放行往下走;返回 false 说明条件还没满足,此时自动释放锁并进入睡眠。小伙伴们千万不要把 true 和 false 的方向记反了。
  • notify_one():由负责准备数据的线程在修改完状态变量后调用,用来唤醒 1 个正在等待该条件变量的线程。

示例 1:等待数据就绪通知

#include <condition_variable>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>

std::mutex mtx;
std::condition_variable cv;
std::string message;
bool ready = false;

void workerTask() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, []() { return ready; });    // 等待 ready 变为 true
    std::cout << "工作线程收到数据:" << message << "\n";
}

int main() {
    std::thread worker(workerTask);

    {
        std::lock_guard<std::mutex> lock(mtx);
        message = "订单数据已就绪";
        ready = true;
    }

    cv.notify_one();                          // 解锁后发通知唤醒线程
    worker.join();
    return 0;
}

运行结果如下。

工作线程收到数据:订单数据已就绪

分析:

在这个例子中,工作线程在 workerTask() 里调用 cv.wait() 等待主线程把数据准备好。为了看清这两个线程是如何配合的,我们可以顺着执行先后拆成 3 步来看:

  • 睡前自动解锁:工作线程先通过 unique_lock 锁住 mtx,再调用 cv.wait() 检查 ready。此时 ready 为 false,工作线程不能抱着锁去睡觉(否则主线程拿不到锁改数据),因此 wait() 会自动调用 lock.unlock() 临时放锁,再让线程进入睡眠。
  • 改状态并按门铃:主线程在局部花括号里通过 std::lock_guard 拿到空闲的 mtx,填好 message 并将 ready 改为 true。走出花括号自动释放锁后,主线程调用 cv.notify_one() 唤醒工作线程。
  • 醒来重拿锁并复查:工作线程被唤醒后,wait() 会先自动调用 lock.lock() 重新拿回互斥锁,再次执行 Lambda 表达式确认 ready 已经为 true,然后才正式放行并打印 message。

这里顺便提一点:上面代码中的 ready 是全局变量,所以 Lambda 表达式的捕获列表写成了空的 []。如果以后我们在函数里判断局部变量或类成员变量,记得在捕获列表里写上 [&] 或 [this],否则编译器会报错提示找不到该变量。

wait() 的循环检查与虚假唤醒

为什么调用 wait() 时还要传入第 2 个条件判断参数呢?其实 cv.wait(lock, []() { return ready; }); 在底层等价于 while (!ready) { cv.wait(lock); } 循环,反复检查 ready 主要是为了解决两个问题:

防止通知先发出而错过信号:假如主线程执行得非常快,在工作线程还没跑到 wait() 之前,就已经把 ready 改成了 true 并按完了门铃。工作线程随后进入 wait() 时会先检查一次 ready,发现已经是 true 就直接往下走,不会再进入睡眠白等。

防止虚假唤醒(spurious wakeup):在底层操作系统中,睡眠中的线程偶尔会在没有任何人调用 notify 的情况下被意外唤醒。有了循环复查之后,哪怕线程被意外吵醒,只要看到 ready 还是 false 就会继续回去睡眠,不会误判条件已经成立。

notify_one() 与 notify_all()

当共享状态修改完毕后,std::condition_variable 提供了两个发送通知的成员函数:

  • notify_one():如果当前有多个线程正在等待,只唤醒其中的 1 个线程。这非常适合 “来了一个新任务,只需要叫醒 1 个空闲线程去处理” 的场景。
  • notify_all():一次性唤醒当前正在等待的 “所有” 线程。这非常适合 “裁判发令枪响了” 或 “全局配置加载完毕”,所有等待线程都需要继续往下执行的场景。

示例 2:广播唤醒多个线程

#include <condition_variable>
#include <iostream>
#include <mutex>
#include <thread>

std::mutex mtx;
std::condition_variable cv;
bool startFlag = false;

void runner(int id) {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, []() { return startFlag; });
    std::cout << "选手 " << id << " 已出发\n";
}

int main() {
    std::thread t1(runner, 1);
    std::thread t2(runner, 2);
    std::thread t3(runner, 3);

    {
        std::lock_guard<std::mutex> lock(mtx);
        startFlag = true;
        std::cout << "裁判:预备,跑!\n";
    }
    cv.notify_all();    // 广播唤醒所有等待线程

    t1.join();
    t2.join();
    t3.join();
    return 0;
}

运行结果如下。

裁判:预备,跑!
选手 1 已出发
选手 2 已出发
选手 3 已出发

分析:

cv.notify_all(); 表示向当前所有等待在 cv 上的线程发出唤醒信号。当主线程把 startFlag 设为 true 并调用 cv.notify_all() 后,t1、t2、t3 三个子线程都会被唤醒。

需要注意的是,“同时被唤醒” 并不代表三个线程会同时冲过 wait() 抢着打印。因为每个线程从 wait() 返回之前,都必须重新拿回互斥锁 mtx,所以它们实际上是排着队依次拿锁、打印并释放锁的。由于每次先抢到锁的线程由系统调度决定,小伙伴们在本地运行时看到的选手出发顺序可能和上面不同。

使用条件变量的注意事项

平时编写条件变量代码时,小伙伴们需要重点留意以下 2 个细节:

  • 发通知前必须先加锁修改状态变量:cv.notify_one() 和 cv.notify_all() 只是一个 “提醒对方起来检查条件” 的门铃,它们本身并不保存任何布尔状态。
  • 推荐先释放锁再发送通知:在通知线程中,我们推荐使用一对局部花括号 {} 包裹 std::lock_guard 修改共享变量。走出花括号让锁先自动释放,随后再调用 notify_one() 或 notify_all(),这样等待线程一醒来就能立刻拿到空闲的锁,不用多等一次。

对于第 1 点,如果通知线程只填了业务数据并按响门铃,却忘了把布尔状态变量 ready 改成 true,就会出问题。因为等待线程醒来复查时看到 ready 依然是 false,会误以为条件还没满足,继续回去睡眠,导致程序一直卡死。因此,下面这种漏改状态变量的写法是错误的。

void notifyBad() {
    {
        std::lock_guard<std::mutex> lock(mtx);
        message = "订单数据已就绪";
        // 漏写 ready = true;
    }
    cv.notify_one();
}

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号