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 到底干了什么?

简单来说,它做了三件事:

  1. 解锁互斥锁,让其他线程能访问共享数据。
  2. 将当前线程挂起,等待被唤醒。
  3. 被唤醒后,重新加锁,然后检查谓词条件。

这个过程是原子的。也就是说,不会出现“刚解锁就被其他线程插进来改了条件”的情况。嗯,这里要注意:条件变量的核心就是“解锁 + 等待”这个原子操作

再看 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 知识体系图

下面这张图总结了本章的核心逻辑:

条件变量核心机制 生产者线程 消费者线程 共享队列 (有界缓冲区) std::mutex std::condition_variable push pop notify_one / notify_all notify_one / notify_all

从这张图可以看得很清楚:生产者往队列里放数据,消费者从队列里取数据,互斥锁保证队列的线程安全,条件变量则负责在队列满或空时挂起线程,并在条件变化时唤醒它们。这四个组件缺一不可。

好了,这一章的内容就到这里。条件变量用好了,你的并发程序会变得既高效又优雅。用不好,那就是各种死锁和性能问题。我个人建议,刚开始用的时候,先写一个简单的生产者-消费者模型跑通,再逐步加复杂逻辑。别一上来就搞多对多,容易把自己绕进去。

记住:条件变量不是用来替代互斥锁的,它们是搭档。一个管资源保护,一个管等待通知。搞清楚了这一点,你就已经超过一半的 C++ 开发者了。


公众号:蓝海资料掘金营,微信 deep3321