13、C++11 并发编程:std::condition_variable,线程间的精准通知。
多线程编程里,最让人头疼的问题是什么?
我个人觉得,不是死锁,也不是数据竞争,而是「线程间怎么精准地叫醒对方」。你想想看,一个线程在等某个条件成立,另一个线程在修改这个条件。如果只是用互斥锁加轮询,CPU 空转得让人心疼。C++11 给出的答案就是 std::condition_variable,说白了,它就是线程间的「闹钟」。
为什么需要 condition_variable?
我在项目中遇到过这样一个场景:一个生产者线程往队列里放数据,多个消费者线程从队列里取数据。如果队列为空,消费者就得等着。最粗暴的做法是让消费者死循环检查队列大小——嗯,这显然不行,CPU 会被吃光。
另一种做法是让消费者 sleep 一小段时间再检查。但 sleep 多久合适?睡短了浪费 CPU,睡长了延迟又高。这时候 condition_variable 就派上用场了:它能让消费者线程「挂起等待」,直到生产者线程「通知」它醒来。这才是真正的精准通知。
核心思想:条件变量 + 互斥锁 = 线程安全的等待与通知机制。条件变量本身不保护数据,它只负责「阻塞」和「唤醒」线程。
基本用法:wait 与 notify
先看一个最简单的例子。一个线程等待某个标志位变成 true,另一个线程去修改它。
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
void worker() {
std::unique_lock<std::mutex> lock(mtx);
// 等待 ready 变为 true
cv.wait(lock, []{ return ready; });
std::cout << "worker 被唤醒了\n";
}
void notifier() {
std::this_thread::sleep_for(std::chrono::seconds(1));
{
std::lock_guard<std::mutex> lock(mtx);
ready = true;
}
cv.notify_one(); // 唤醒一个等待的线程
}
int main() {
std::thread t1(worker);
std::thread t2(notifier);
t1.join();
t2.join();
return 0;
}
注意看 wait 的第二个参数——一个 lambda 表达式。这是 C++11 提供的重载版本,它会自动检查条件是否满足。如果条件为 false,线程阻塞;如果为 true,直接返回。这比手动写 while 循环优雅多了。
我的习惯:永远使用带谓词的 wait 版本。为什么?因为它能防止「虚假唤醒」。操作系统在某些情况下会无缘无故唤醒一个等待的线程,如果没有谓词检查,线程可能以为条件满足了,实际上并没有。我曾经因为这个 bug 排查了整整一个下午。
notify_one 与 notify_all 的区别
这两个函数很容易混淆。我简单总结一下:
| 函数 | 行为 | 适用场景 |
|---|---|---|
notify_one() |
唤醒一个等待的线程(具体哪个由调度器决定) | 生产者-消费者模型,一个任务只需要一个消费者处理 |
notify_all() |
唤醒所有等待的线程 | 广播通知,比如所有线程都需要停止工作 |
你想想看,如果多个消费者线程都在等同一个任务,你用 notify_all 会怎样?所有线程都被唤醒,但只有一个能抢到任务,其他线程发现条件不满足后又继续阻塞。这就是所谓的「惊群效应」,白白浪费了上下文切换的开销。
注意:notify_one 和 notify_all 必须在互斥锁解锁之后调用,或者至少不在锁的持有期间调用。虽然技术上可以在锁内调用,但这样做可能导致被唤醒的线程立即阻塞在锁上,反而降低了效率。我一般习惯在解锁后调用。
生产者-消费者队列的完整实现
下面是一个我在实际项目中用过的线程安全队列。它支持多个生产者和多个消费者。
#include <queue>
#include <mutex>
#include <condition_variable>
template<typename T>
class ThreadSafeQueue {
public:
void push(T value) {
std::lock_guard<std::mutex> lock(mtx_);
queue_.push(std::move(value));
cv_.notify_one(); // 通知一个消费者
}
bool try_pop(T& value) {
std::lock_guard<std::mutex> lock(mtx_);
if (queue_.empty()) return false;
value = std::move(queue_.front());
queue_.pop();
return true;
}
void wait_and_pop(T& value) {
std::unique_lock<std::mutex> lock(mtx_);
cv_.wait(lock, [this]{ return !queue_.empty(); });
value = std::move(queue_.front());
queue_.pop();
}
private:
std::queue<T> queue_;
std::mutex mtx_;
std::condition_variable cv_;
};
这个队列有两个弹出操作:try_pop 是非阻塞的,队列为空就返回 false;wait_and_pop 是阻塞的,直到有数据才返回。你想想看,这种设计给了调用者灵活的选择——有些场景需要立即返回,有些场景可以耐心等待。
条件变量的常见陷阱
嗯,这里要注意几个容易踩坑的地方。
- 虚假唤醒:前面已经提过,一定要用带谓词的
wait,或者在wait返回后手动检查条件。 - 丢失唤醒:如果在
notify_one调用时没有线程在等待,这个通知就丢失了。所以一定要确保条件变量的使用逻辑是「先等待,后通知」的闭环。 - 锁的类型:
wait必须配合std::unique_lock使用,不能使用std::lock_guard。因为wait在阻塞期间需要解锁互斥量,醒来后需要重新加锁,unique_lock提供了这种灵活性。
我曾经踩过的坑:有一次我写了一个多线程下载器,多个线程等待下载任务。我用了 notify_all 来唤醒所有线程,结果发现 CPU 使用率飙升。后来才意识到,所有线程被唤醒后都去抢同一个任务,抢不到的又继续阻塞。改成 notify_one 后,问题立刻解决了。
知识体系图
下面这张图展示了 condition_variable 在整个并发编程中的位置和核心流程:
性能与最佳实践
条件变量不是万能的。如果等待的时间非常短(比如几微秒),用自旋锁可能更合适。但大多数情况下,条件变量是线程间同步的首选。
我个人总结了几条经验:
- 尽量缩小临界区:在
notify之前,只修改必要的共享数据,然后尽快解锁。 - 优先使用
notify_one:除非你明确需要唤醒所有线程,否则不要用notify_all。 - 考虑超时版本:
wait_for和wait_until可以设置最长等待时间,防止线程永久阻塞。 - 不要依赖条件变量做事件计数:条件变量不保证通知的顺序,也不保证每个通知都被消费。如果需要精确的事件计数,考虑用信号量或
std::promise。
一个小技巧:调试条件变量相关的 bug 时,可以在 wait 和 notify 前后加日志,打印线程 ID 和时间戳。这样能快速定位是哪个线程在等,哪个线程在通知。我曾经靠这个办法半小时就找到了一个隐藏的竞态条件。
好了,关于 std::condition_variable 的核心内容就这些。它不复杂,但用好了能让你的多线程代码既高效又优雅。记住一点:条件变量是「通知」机制,不是「数据保护」机制。数据的安全还得靠互斥锁来保证。