7. 条件变量:std::condition_variable 的使用、wait 与 notify 机制、生产者-消费者模型实战
大家好,欢迎来到第七章。
这一章我们聊聊条件变量。说实话,条件变量是并发编程里最容易让人头疼的东西之一。我刚开始学的时候,总觉得它跟互斥锁差不多,后来在项目里踩了坑才明白——它俩干的根本不是同一件事。
7.1 为什么需要条件变量?
先问一个问题:如果线程 A 要等某个条件成立才能继续干活,你会怎么做?
最简单的办法——让线程 A 一直轮询检查。比如:
while (!ready) {
// 空转,浪费 CPU
}
嗯,这确实能工作。但代价呢?CPU 被白白吃掉,尤其是当条件要等很久才满足时,这种忙等待简直就是灾难。
我在早期的一个网络服务器项目里就干过这种事。当时压测一上来,CPU 直接飙到 100%,但实际吞吐量却上不去。排查了半天,发现就是几个工作线程在死循环里轮询任务队列。说白了,这就是典型的“用蛮力解决问题”。
条件变量就是来解决这个问题的。它让线程在条件不满足时挂起等待,而不是空转。等条件满足了,再由另一个线程把它唤醒。这样既省 CPU,又及时。
核心思想:条件变量 + 互斥锁 = 高效的等待与通知机制。
7.2 std::condition_variable 的基本用法
C++11 提供了 std::condition_variable,它需要配合 std::unique_lock<std::mutex> 使用。为什么不能用 std::lock_guard?因为 wait 内部需要临时解锁,等条件满足后再重新加锁,lock_guard 不支持这种操作。
来看一个最简单的例子:
#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);
cv.wait(lock, []{ return ready; }); // 等待 ready 变为 true
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),它会在被唤醒后再次检查条件。如果条件不满足,继续等待。这叫虚假唤醒防护。notify_one只唤醒一个线程。如果有多个线程在等,可以用notify_all。
我的习惯:永远给 wait 传一个谓词。别偷懒。我曾经见过一个同事没传谓词,结果程序偶尔会莫名其妙地提前唤醒,查了两天才发现是虚假唤醒的问题。
7.3 wait 与 notify 的底层机制
你可能会好奇:wait 到底干了什么?
简单来说,它做了三件事:
- 解锁互斥锁,让其他线程能访问共享数据。
- 将当前线程挂起,等待被唤醒。
- 被唤醒后,重新加锁,然后检查谓词条件。
这个过程是原子的。也就是说,不会出现“刚解锁就被其他线程插进来改了条件”的情况。嗯,这里要注意:条件变量的核心就是“解锁 + 等待”这个原子操作。
再看 notify_one:它只是发一个信号,告诉系统“条件可能变了,去检查一下”。它不会立即唤醒线程,而是由调度器决定什么时候唤醒。所以,notify 之后,等待线程可能不会马上执行——这很正常。
我曾经踩过的坑:在 notify_one 之后,以为等待线程已经拿到了锁,就直接去修改共享数据。结果发现数据被改了两次。原因很简单——notify 只是唤醒,线程拿到锁之前还有一段窗口期。正确的做法是:notify 之后,尽快释放锁。
7.4 生产者-消费者模型实战
好了,理论说完了。我们直接上一个经典的生产者-消费者模型。这是条件变量最典型的应用场景。
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <chrono>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> data_queue;
const int MAX_QUEUE_SIZE = 10;
void producer(int id) {
for (int i = 0; i < 20; ++i) {
std::unique_lock<std::mutex> lock(mtx);
// 队列满了就等待消费者消费
cv.wait(lock, []{ return data_queue.size() < MAX_QUEUE_SIZE; });
int value = id * 100 + i;
data_queue.push(value);
std::cout << "生产者 " << id << " 生产: " << value << "\n";
lock.unlock(); // 提前解锁,减少锁竞争
cv.notify_all(); // 通知消费者
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
}
void consumer(int id) {
for (int i = 0; i < 20; ++i) {
std::unique_lock<std::mutex> lock(mtx);
// 队列空了就等待生产者生产
cv.wait(lock, []{ return !data_queue.empty(); });
int value = data_queue.front();
data_queue.pop();
std::cout << "消费者 " << id << " 消费: " << value << "\n";
lock.unlock();
cv.notify_all(); // 通知生产者
std::this_thread::sleep_for(std::chrono::milliseconds(80));
}
}
int main() {
std::thread p1(producer, 1);
std::thread p2(producer, 2);
std::thread c1(consumer, 1);
std::thread c2(consumer, 2);
p1.join();
p2.join();
c1.join();
c2.join();
return 0;
}
这个模型里,我故意用了两个生产者和两个消费者。你想想看,如果只有一个生产者和一个消费者,那其实跟单线程差不多,体现不出并发的好处。多对多的情况下,条件变量的价值就出来了——谁等谁唤醒,全靠条件。
这里有几个设计要点:
- 队列有界:防止生产者无限制地生产,导致内存爆炸。
- 提前解锁:在
notify之前先unlock,这样被唤醒的线程能立刻拿到锁,减少上下文切换。 - 使用
notify_all:因为可能有多个生产者和消费者在等,只唤醒一个可能不够。
我的建议:如果生产者和消费者数量不多(比如 2-4 个),用 notify_all 没问题。但如果线程数很多(比如几十个),notify_all 会导致“惊群效应”——所有线程都被唤醒,但只有一个能拿到锁。这时候可以考虑用 notify_one 或者更精细的调度策略。
7.5 条件变量的常见陷阱
最后,我总结几个我在项目中遇到过的坑:
| 陷阱 | 说明 | 解决方法 |
|---|---|---|
| 忘记传谓词 | 直接 cv.wait(lock),容易被虚假唤醒坑 |
永远用带谓词的重载 |
在 notify 之后还持有锁 |
被唤醒的线程拿不到锁,白白等待 | 先 unlock,再 notify |
用 lock_guard 配合条件变量 |
wait 需要临时解锁,lock_guard 不支持 |
必须用 unique_lock |
| 条件变量与互斥锁不匹配 | 多个条件变量共用一把锁,逻辑混乱 | 每个条件变量配一把锁,或者用同一个锁但仔细设计条件 |
我曾经在一个多线程下载器里犯过第二个错误。当时为了省事,在 notify_all 之后才释放锁,结果消费者线程被唤醒了却拿不到锁,又睡回去了。整个程序跑起来跟单线程一样慢。嗯,从那以后我就记住了:先解锁,再通知。
7.6 知识体系图
下面这张图总结了本章的核心逻辑:
从这张图可以看得很清楚:生产者往队列里放数据,消费者从队列里取数据,互斥锁保证队列的线程安全,条件变量则负责在队列满或空时挂起线程,并在条件变化时唤醒它们。这四个组件缺一不可。
好了,这一章的内容就到这里。条件变量用好了,你的并发程序会变得既高效又优雅。用不好,那就是各种死锁和性能问题。我个人建议,刚开始用的时候,先写一个简单的生产者-消费者模型跑通,再逐步加复杂逻辑。别一上来就搞多对多,容易把自己绕进去。
记住:条件变量不是用来替代互斥锁的,它们是搭档。一个管资源保护,一个管等待通知。搞清楚了这一点,你就已经超过一半的 C++ 开发者了。