4. 线程同步之条件变量
说到多线程同步,大家第一反应肯定是互斥锁。但锁只能解决「能不能进」的问题,解决不了「什么时候该进」的问题。你想想看,一个线程在循环里不断检查某个条件是否满足,这得多浪费CPU?
条件变量就是来解决这个痛点的。它让线程可以「睡」在那里,直到某个条件成立才被唤醒。我个人觉得,条件变量是C++多线程里最优雅的同步机制之一。
条件变量工作机制
条件变量本质上是一个等待队列。它的工作流程其实很简单:
- 等待(wait):线程发现条件不满足,主动挂起自己,释放锁
- 通知(notify):另一个线程改变了条件,通知等待的线程醒来
- 重新检查:被唤醒的线程重新获取锁,再次检查条件
这里有个关键点:wait() 操作是原子的。它会先释放锁,再把线程挂起。这两个动作之间不会被打断。我在项目中见过有人自己用锁和循环模拟条件变量,结果总是出bug,就是因为忽略了原子性。
核心机制:条件变量必须和互斥锁配合使用。没有锁的条件变量就像没有刹车的车——根本没法用。
生产者-消费者模型
这是条件变量最经典的场景。我当年刚学多线程时,就是靠这个模型彻底搞懂了条件变量。说白了,就是两个角色:
- 生产者:往队列里放数据,放完后通知消费者
- 消费者:从队列里取数据,队列空时就等着
来看一个完整的代码示例:
#include <queue>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <iostream>
std::queue<int> g_queue;
std::mutex g_mutex;
std::condition_variable g_cv;
const int MAX_ITEMS = 10;
void Producer() {
for (int i = 0; i < 20; ++i) {
std::unique_lock<std::mutex> lock(g_mutex);
// 队列满了就等着
g_cv.wait(lock, []{ return g_queue.size() < MAX_ITEMS; });
g_queue.push(i);
std::cout << "生产: " << i << std::endl;
lock.unlock();
g_cv.notify_one(); // 通知一个消费者
}
}
void Consumer() {
for (int i = 0; i < 20; ++i) {
std::unique_lock<std::mutex> lock(g_mutex);
// 队列空了就等着
g_cv.wait(lock, []{ return !g_queue.empty(); });
int val = g_queue.front();
g_queue.pop();
std::cout << "消费: " << val << std::endl;
lock.unlock();
g_cv.notify_one(); // 通知生产者继续生产
}
}
这段代码里有个细节值得注意:wait() 的第二个参数是个lambda表达式。这个lambda就是条件检查函数。为什么要传它?嗯,这里就要说到虚假唤醒了。
虚假唤醒问题
虚假唤醒,说白了就是线程被唤醒了,但条件其实还没满足。为什么会这样?
我记得有一次排查线上问题,发现消费者线程偶尔会从空队列里取数据。查了半天,原来是操作系统层面的原因:pthread_cond_wait 在某些系统实现中,即使没有收到通知,也可能返回。这就是典型的虚假唤醒。
重要警告:永远不要假设线程被唤醒时条件一定满足。必须用while循环或带谓词的wait来重新检查条件。
正确的做法有两种:
// 方式一:while循环(推荐)
while (queue.empty()) {
cv.wait(lock);
}
// 方式二:带谓词的wait(本质也是循环)
cv.wait(lock, []{ return !queue.empty(); });
我个人习惯用方式二,代码更简洁。但你要知道,这两种方式在底层是一样的——wait 内部其实就是一个while循环。
std::condition_variable 详解
C++11 提供了 std::condition_variable,它有几个核心方法:
| 方法 | 说明 | 注意事项 |
|---|---|---|
| wait(lock, predicate) | 等待条件成立 | 必须配合 unique_lock |
| notify_one() | 唤醒一个等待线程 | 不释放锁,只是通知 |
| notify_all() | 唤醒所有等待线程 | 慎用,可能引起惊群效应 |
| wait_for(lock, duration, predicate) | 带超时的等待 | 超时后自动返回 |
这里有个容易踩的坑:notify_one() 和 notify_all() 应该在锁释放后调用,还是在锁内调用?
我曾经在这个问题上纠结了很久。后来看了Linux内核的pthread实现,发现两种方式都可以。但如果在锁内调用,被唤醒的线程会立即尝试获取锁,可能造成不必要的上下文切换。所以我建议:
我的习惯:先释放锁,再调用 notify。这样被唤醒的线程可以直接拿到锁,减少竞争。
// 推荐的做法
{
std::lock_guard<std::mutex> lock(mutex);
// 修改共享数据
data_ready = true;
}
cv.notify_one(); // 锁已释放,再通知
还有一个 std::condition_variable_any,它可以用任何可锁定的对象,不限于 unique_lock。但性能比 std::condition_variable 差一些。我一般只在需要特殊锁类型时才用它。
避坑指南:我曾经在项目中遇到过一个死锁问题——生产者通知消费者时,消费者还在等待锁。排查后发现是 notify 调用时锁还没释放。记住:notify 只是唤醒线程,不负责传递锁。被唤醒的线程需要自己重新获取锁。
最后说个实用技巧:如果你有多个消费者,用 notify_one() 就够了。只有当你需要广播某个状态变化时,才用 notify_all()。比如「所有线程都停下来」这种场景。
条件变量用好了,能让你的多线程程序既高效又优雅。但用不好,各种诡异bug就会找上门来。记住三点:必须配锁、必须检查条件、小心虚假唤醒。做到这三点,基本就不会出大问题。
总结一下:条件变量不是魔法,它只是让线程在合适的时候睡觉和醒来。用好它,你的多线程程序会像流水线一样顺畅。用不好,那就是一场噩梦。我建议你多写几个生产者-消费者的demo,把各种边界情况都跑一遍,自然就理解了。
可可精品资料库