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_onenotify_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 在整个并发编程中的位置和核心流程:

std::condition_variable 核心知识体系 等待机制 • wait(lock, predicate) • wait_for(lock, duration, pred) • wait_until(lock, time, pred) 通知机制 • notify_one() • notify_all() • 解锁后调用更高效 配套工具 • std::unique_lock • std::mutex • 谓词(lambda/函数) 典型应用场景 生产者-消费者队列 | 线程池任务分发 | 异步事件通知 | 屏障同步 ⚠ 注意:必须配合 unique_lock 使用 | 警惕虚假唤醒 | 避免丢失通知

性能与最佳实践

条件变量不是万能的。如果等待的时间非常短(比如几微秒),用自旋锁可能更合适。但大多数情况下,条件变量是线程间同步的首选。

我个人总结了几条经验:

  • 尽量缩小临界区:notify 之前,只修改必要的共享数据,然后尽快解锁。
  • 优先使用 notify_one除非你明确需要唤醒所有线程,否则不要用 notify_all
  • 考虑超时版本:wait_forwait_until 可以设置最长等待时间,防止线程永久阻塞。
  • 不要依赖条件变量做事件计数:条件变量不保证通知的顺序,也不保证每个通知都被消费。如果需要精确的事件计数,考虑用信号量或 std::promise

一个小技巧:调试条件变量相关的 bug 时,可以在 waitnotify 前后加日志,打印线程 ID 和时间戳。这样能快速定位是哪个线程在等,哪个线程在通知。我曾经靠这个办法半小时就找到了一个隐藏的竞态条件。

好了,关于 std::condition_variable 的核心内容就这些。它不复杂,但用好了能让你的多线程代码既高效又优雅。记住一点:条件变量是「通知」机制,不是「数据保护」机制。数据的安全还得靠互斥锁来保证。

专注资料整理