【C++】条件变量:wait、notify_one 与 notify_all 到底怎么工作的
前面介绍互斥锁时,我们解决的是:
多个线程同时访问共享数据时,如何保证同一时刻只有一个线程进入临界区。
但是互斥锁还有一个解决不了的问题。
假设线程池中的工作线程需要不断检查任务队列:
while (true) { std::lock_guard<std::mutex> lock(task_mutex); if (!tasks.empty()) { // 取任务 } }如果队列一直为空,线程就会不停循环:
检查队列 ↓ 没有任务 ↓ 继续检查 ↓ 还是没有 ↓ 继续检查……这样会白白消耗 CPU。
我们真正希望的是:
没有任务 ↓ 线程睡眠 ↓ 其他线程放入任务 ↓ 唤醒工作线程 ↓ 继续处理任务这就是std::condition_variable条件变量的作用。
一、condition_variable 到底解决什么问题
先定义:
#include <condition_variable> #include <mutex> #include <queue> std::mutex mutex; std::condition_variable condition; std::queue<int> tasks;工作线程可以这样等待任务:
void Worker() { std::unique_lock<std::mutex> lock(mutex); condition.wait(lock, []() { return !tasks.empty(); }); int task = tasks.front(); tasks.pop(); }生产者线程加入任务:
void AddTask(int task) { { std::lock_guard<std::mutex> lock(mutex); tasks.push(task); } condition.notify_one(); }整个过程是:
Worker线程 ↓ 检查tasks.empty() ↓ 为空 ↓ condition.wait() ↓ 释放mutex并睡眠 AddTask线程 ↓ 获得mutex ↓ tasks.push() ↓ 释放mutex ↓ notify_one() ↓ 唤醒Worker这里一定要注意:
条件变量本身并不保存“任务”。
真正的数据还是:
std::queue<int> tasks;条件变量只是负责:
等待某个条件成立 + 通知等待线程重新检查条件所以:
condition.notify_one();并不是把任务传递给工作线程。
真正传递的数据仍然放在:
tasks里面。
二、wait(lock, predicate) 到底是怎么执行的
线程池中最常见的一句代码就是:
condition.wait(lock, [this]() { return stop_ || !tasks_.empty(); });这句代码第一次看确实很容易懵。
先把 Lambda 单独拿出来:
[this]() { return stop_ || !tasks_.empty(); }它本质上就是一个函数。
可以简单理解成:
bool Check() { return stop_ || !tasks_.empty(); }所以:
condition.wait(lock, [this]() { return stop_ || !tasks_.empty(); });逻辑上类似:
condition.wait(lock, Check);而带谓词版本的wait()可以近似理解成:
while (!(stop_ || !tasks_.empty())) { condition.wait(lock); }也就是说,它会不断检查:
stop_ || !tasks_.empty()情况一:返回 true
例如:
stop_ = false tasks_不为空那么:
stop_ || !tasks_.empty()结果就是:
false || true = true这时候:
condition.wait(lock, predicate);不会进入等待,直接继续往下执行。
可以理解成:
condition.wait(lock, true);虽然真正的函数并不是这样传参,但从理解执行结果的角度可以这么看。
情况二:返回 false
例如:
stop_ = false tasks_为空得到:
false || false = false这时候线程就需要等待。
但wait()做的事情不是简单的“停在这里”。
它实际上会完成:
当前线程持有mutex ↓ predicate返回false ↓ wait自动释放mutex ↓ 当前线程进入睡眠 ↓ 其他线程notify ↓ 当前线程被唤醒 ↓ 重新获取mutex ↓ 再次检查predicate这里最重要的一点是:
wait()在睡眠期间会释放互斥锁。
否则就会出问题。
假设工作线程睡眠时还一直拿着:
mutex那么生产者想执行:
tasks.push(task);也需要这把锁:
std::lock_guard<std::mutex> lock(mutex);结果就变成:
Worker: 拿着mutex睡觉 Producer: 想拿mutex加入任务 ↓ 拿不到 Worker: 等Producer添加任务 ↓ Producer又因为拿不到mutex无法添加程序就卡住了。
所以wait()必须:
睡眠前释放锁 唤醒后重新获得锁三、为什么必须使用 unique_lock
因此我们就能理解一个非常经典的问题:
为什么条件变量要写:
std::unique_lock<std::mutex> lock(mutex); condition.wait(lock);而不能写:
std::lock_guard<std::mutex> lock(mutex); condition.wait(lock);原因就在于:
condition_variable::wait()内部需要控制这把锁:
unlock ↓ 睡眠 ↓ lock而lock_guard的设计非常简单:
构造 → 加锁 析构 → 解锁它不允许中途主动:
lock.unlock(); lock.lock();而unique_lock可以:
std::unique_lock<std::mutex> lock(mutex); lock.unlock(); lock.lock();所以条件变量需要unique_lock。
可以把它理解成:
lock_guard: “我拿到锁以后一直拿到作用域结束。” unique_lock: “我负责管理这把锁,但中途允许释放和重新获取。”而wait()恰好就需要第二种能力。
例如:
std::unique_lock<std::mutex> lock(mutex); condition.wait(lock, []() { return !tasks.empty(); });内部大致相当于:
检查tasks ↓ 没有任务 ↓ lock.unlock() ↓ 线程睡眠 ↓ 被notify唤醒 ↓ lock.lock() ↓ 再次检查tasks因此在线程池、阻塞队列、生产者消费者模型中,unique_lock + condition_variable经常一起出现。
四、notify_one、notify_all 与虚假唤醒
当生产者添加新任务以后,需要通知正在等待的线程:
condition.notify_one();或者:
condition.notify_all();notify_one
condition.notify_one();表示:
唤醒一个正在等待这个条件变量的线程。
假设有 4 个工作线程:
Worker1:等待 Worker2:等待 Worker3:等待 Worker4:等待加入一个任务:
tasks.push(task); condition.notify_one();只需要唤醒其中一个线程处理任务即可。
因此线程池添加普通任务时,一般使用:
condition.notify_one();notify_all
condition.notify_all();表示:
唤醒所有正在等待的线程。
例如线程池准备关闭:
stop_ = true; condition.notify_all();此时:
Worker1 Worker2 Worker3 Worker4都需要知道:
线程池准备退出所以应该全部唤醒。
典型写法:
{ std::lock_guard<std::mutex> lock(tasks_mutex_); stop_ = true; } condition_.notify_all();所有线程醒来以后重新检查:
return stop_ || !tasks_.empty();因为:
stop_ == true所以谓词返回:
true线程不再继续等待,而是进入后面的退出逻辑。
为什么唤醒后还要重新检查条件
因为条件变量存在一个重要现象:
Spurious Wakeup 虚假唤醒也就是说:
即使没有调用
notify_one()或notify_all(),等待线程也有可能被唤醒。
所以不能这样写:
condition.wait(lock); // 醒了就默认一定有任务 int task = tasks.front();因为线程醒来以后:
tasks可能仍然为空。
传统写法应该是:
while (tasks.empty()) { condition.wait(lock); }也就是:
醒来 ↓ 重新检查条件 ↓ 条件不满足 ↓ 继续睡眠而现代 C++ 更推荐:
condition.wait(lock, []() { return !tasks.empty(); });因为带谓词的版本已经帮我们处理了:
while (!predicate()) { wait(lock); }所以线程池中的:
condition_.wait(lock, [this]() { return stop_ || !tasks_.empty(); });写法实际上非常标准。
五、完整生产者—消费者示例
最后用一个简单任务队列,把整个流程串起来。
#include <condition_variable> #include <iostream> #include <mutex> #include <queue> #include <thread> std::queue<int> tasks; std::mutex tasks_mutex; std::condition_variable condition; bool stop = false;消费者线程:
void Worker() { while (true) { std::unique_lock<std::mutex> lock(tasks_mutex); condition.wait(lock, []() { return stop || !tasks.empty(); }); if (stop && tasks.empty()) { return; } int task = tasks.front(); tasks.pop(); lock.unlock(); std::cout << "处理任务:" << task << '\n'; } }生产者:
void AddTask(int task) { { std::lock_guard<std::mutex> lock(tasks_mutex); tasks.push(task); } condition.notify_one(); }主函数:
int main() { std::thread worker1(Worker); std::thread worker2(Worker); AddTask(100); AddTask(200); AddTask(300); { std::lock_guard<std::mutex> lock(tasks_mutex); stop = true; } condition.notify_all(); worker1.join(); worker2.join(); return 0; }这里有一个值得注意的地方:
int task = tasks.front(); tasks.pop(); lock.unlock(); std::cout << "处理任务:" << task << '\n';为什么取出任务后主动:
lock.unlock();因为互斥锁只需要保护:
访问tasks队列真正执行任务不应该一直占着锁。
否则:
Worker1拿到锁 ↓ 取出任务 ↓ 一直拿着锁执行任务 ↓ Worker2无法访问任务队列线程池就会失去并发执行的意义。
所以正确思路一般是:
加锁 ↓ 访问共享任务队列 ↓ 取出任务 ↓ 解锁 ↓ 真正执行任务这也是unique_lock灵活性的一个典型体现。
整个生产者—消费者模型可以总结为:
生产者 ↓ 获得mutex ↓ 加入任务 ↓ 释放mutex ↓ notify_one 消费者 ↓ 获得mutex ↓ 检查条件 ↓ 没有任务 ↓ wait释放mutex并睡眠 ↓ 被唤醒 ↓ 重新获得mutex ↓ 再次检查条件 ↓ 取出任务 ↓ 释放mutex ↓ 执行任务这一篇最需要掌握的其实就是下面几句话:
condition_variable用于“等待某个条件成立”; wait睡眠时会自动释放mutex, 醒来后会重新获得mutex; 正因为需要中途unlock和lock, 所以wait通常配合unique_lock; predicate返回true时不等待, 返回false时进入等待; 线程醒来以后必须重新检查条件, 因此推荐使用带predicate的wait; notify_one通常唤醒一个工作线程; notify_all通常用于退出、状态变化等 需要所有等待线程都重新检查条件的场景。如果把线程池中的这一句真正理解:
condition_.wait(lock, [this]() { return stop_ || !tasks_.empty(); });那么condition_variable最核心的部分基本就掌握了。
0voice · GitHub