34、多线程编程(下):条件变量、原子操作与线程安全队列

好,咱们接着聊多线程。上一章我们把互斥锁、死锁这些基础概念捋了一遍。今天要聊的这几个东西——条件变量、原子操作、内存序——说实话,是真正区分「会用」和「精通」的分水岭。

我个人习惯把多线程编程分成三个层次:
第一层,会用锁,能保证数据不打架。
第二层,会用条件变量,能高效地等消息。
第三层,理解内存序,能写出无锁或半无锁的高性能代码。

今天这一章,咱们就把第二层和第三层打通。

一、条件变量(condition_variable)——别让线程空转

先问一个问题:如果一个线程要等某个条件成立,比如队列里有数据了再处理,你会怎么写?

新手最容易犯的错误是——忙等待

// 错误示范:忙等待
while (queue.empty()) {
    // 啥也不干,就死循环检查
}

这玩意儿有多坑?CPU 会被这个线程吃满,其他线程根本跑不动。我在项目中遇到过有人这么写,上线后服务器 CPU 直接飙到 100%,查了半天才发现是这里在空转。

正确的做法是用 条件变量。它的核心思想很简单:
「条件不满足时,线程去睡觉;条件满足了,有人把它叫醒。」

看一个标准的生产者-消费者模型:

#include <condition_variable>
#include <mutex>
#include <queue>

std::mutex mtx;
std::condition_variable cv;
std::queue<int> data_queue;

// 生产者
void producer() {
    for (int i = 0; i < 100; ++i) {
        {
            std::lock_guard<std::mutex> lock(mtx);
            data_queue.push(i);
        }
        cv.notify_one();  // 通知一个等待的消费者
    }
}

// 消费者
void consumer() {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx);
        cv.wait(lock, []{ return !data_queue.empty(); });
        // 条件满足,取出数据
        int val = data_queue.front();
        data_queue.pop();
        lock.unlock();
        // 处理数据...
    }
}

这里有几个关键点,我得强调一下:

  • 必须用 unique_lock,不能用 lock_guard。因为 wait() 内部需要解锁和重新加锁,lock_guard 不支持这个操作。
  • 第二个参数是 lambda 判断条件。这叫「虚假唤醒防护」。为什么会有虚假唤醒?嗯,操作系统底层的原因,有些平台在信号中断时会把所有等待线程都唤醒。所以你必须用 while 循环或带谓词的 wait 来二次检查。
  • notify_one vs notify_all:如果只有一个消费者在等,用 notify_one;如果有多个消费者在等同一个条件,用 notify_all。用错了会导致死锁或性能问题。
⚠️ 避坑指南: 我曾经在项目里犯过一个低级错误——在加锁之前调用了 notify_one()。结果消费者线程被唤醒了,但锁还没释放,消费者拿不到锁又睡回去了。后来查了半天才发现,notify 必须在锁释放之后调用,或者至少保证锁不持有时再通知。

二、原子操作(std::atomic)——比锁更轻量

锁虽然好用,但开销不小。每次加锁解锁,内核态切换、上下文保存,都是成本。如果你只是对一个整数做加减,用锁就有点「杀鸡用牛刀」了。

原子操作 就是干这个的。它直接利用 CPU 提供的原子指令(比如 x86 的 LOCK 前缀),保证操作的不可分割性。

#include <atomic>

std::atomic<int> counter{0};

void increment() {
    for (int i = 0; i < 1000; ++i) {
        counter.fetch_add(1);  // 原子加1
    }
}

// 或者用重载的运算符
counter++;  // 也是原子的

你看,不需要锁,不需要 mutex,一行代码搞定。性能比锁高一个数量级。

但要注意,不是所有类型都能做成原子的。标准库支持整数类型、指针、以及一些 trivial 类型。如果你的结构体很大,std::atomic 内部会退化成用锁实现,那就没意义了。

💡 小技巧: 我习惯用 std::atomic<bool> 做线程间的「停止标志」。比如一个工作线程循环处理任务,主线程想让它停下来,只需要把 atomic<bool> stop{false} 设为 true。工作线程每次循环检查一下,优雅退出。比用 pthread_cancel 安全一万倍。

三、内存序(memory_order)——最烧脑的部分

好,接下来是重头戏。内存序这个东西,说实话,我刚开始学的时候也绕了很久。但一旦理解了,你会发现它其实没那么玄乎。

先问一个问题:
线程 A 写了一个变量,线程 B 读这个变量。在多核 CPU 上,B 一定能看到 A 写的最新值吗?

答案是:不一定

为什么?因为 CPU 和编译器会做优化。指令可能重排,缓存可能没刷新。你看到的「顺序」和实际执行的顺序可能不一样。

std::atomic 提供了六种内存序,但常用的就三种:

内存序 含义 性能
memory_order_relaxed 只保证原子性,不保证顺序 最快
memory_order_acquire 保证后续读写不会重排到本操作之前 中等
memory_order_release 保证前面的读写不会重排到本操作之后 中等
memory_order_seq_cst 全局顺序一致,最严格 最慢(默认)

默认是 memory_order_seq_cst,最安全但最慢。如果你对性能有极致要求,可以考虑用 relaxedacquire/release

看个例子:

std::atomic<int> data{0};
std::atomic<bool> ready{false};

// 线程1:写数据
void writer() {
    data.store(42, std::memory_order_release);
    ready.store(true, std::memory_order_release);
}

// 线程2:读数据
void reader() {
    while (!ready.load(std::memory_order_acquire));
    // 此时保证能看到 data 的最新值
    int val = data.load(std::memory_order_acquire);
}

这里 releaseacquire 配对使用,形成了一种「同步关系」。线程1在 release 之前的所有写入,线程2在 acquire 之后都能看到。

🔑 核心原则: release 和 acquire 必须成对出现。一个线程 release,另一个线程 acquire,才能建立 happens-before 关系。如果两边都用 relaxed,那就啥保证都没有。

四、线程安全队列实现——把理论串起来

理论说完了,咱们动手写一个真正的线程安全队列。这个实现我在好几个项目里都用过,算是比较成熟的模板。

#include <queue>
#include <mutex>
#include <condition_variable>
#include <optional>

template<typename T>
class ThreadSafeQueue {
public:
    ThreadSafeQueue() = default;

    // 禁止拷贝
    ThreadSafeQueue(const ThreadSafeQueue&) = delete;
    ThreadSafeQueue& operator=(const ThreadSafeQueue&) = delete;

    void push(T value) {
        std::lock_guard<std::mutex> lock(m_mutex);
        m_queue.push(std::move(value));
        m_cv.notify_one();
    }

    // 阻塞式弹出
    T pop() {
        std::unique_lock<std::mutex> lock(m_mutex);
        m_cv.wait(lock, [this]{ return !m_queue.empty(); });
        T value = std::move(m_queue.front());
        m_queue.pop();
        return value;
    }

    // 非阻塞式弹出
    std::optional<T> try_pop() {
        std::lock_guard<std::mutex> lock(m_mutex);
        if (m_queue.empty()) {
            return std::nullopt;
        }
        T value = std::move(m_queue.front());
        m_queue.pop();
        return value;
    }

    bool empty() const {
        std::lock_guard<std::mutex> lock(m_mutex);
        return m_queue.empty();
    }

    size_t size() const {
        std::lock_guard<std::mutex> lock(m_mutex);
        return m_queue.size();
    }

private:
    mutable std::mutex m_mutex;
    std::condition_variable m_cv;
    std::queue<T> m_queue;
};

这个实现有几个设计要点:

  • std::optional 处理非阻塞弹出:调用者可以判断是否取到了值,不用抛异常。
  • mutable 修饰 m_mutex:因为 empty()size() 是 const 方法,但需要加锁。用 mutable 可以绕过 const 限制。
  • 移动语义pushpop 都用 std::move,避免不必要的拷贝。
⚠️ 注意: 这个队列的 pop() 是阻塞的。如果队列永远不会再有数据,线程会一直卡在 wait() 里。实际项目中,我通常会加一个「停止」标志,或者用超时等待。比如 wait_for()wait_until()

五、知识体系总览

下面这张图把本章的核心知识点串起来了。你想想看,从最底层的 CPU 原子指令,到上层的线程安全容器,每一层都有它的职责。

多线程编程(下)知识体系 基础层:互斥锁(std::mutex) 保证互斥访问,但开销大,适合临界区较大的场景 条件变量(condition_variable) 避免忙等待,线程在条件不满足时休眠,被通知后唤醒 原子操作(std::atomic) 利用CPU原子指令,比锁更轻量,适合简单变量的读写 内存序(memory_order) 控制指令重排和缓存可见性,release/acquire成对使用

你看,从下往上,每一层都是对上一层的补充和优化。互斥锁解决互斥问题,条件变量解决等待问题,原子操作解决轻量同步问题,内存序解决顺序一致性问题。它们不是互相替代的关系,而是各司其职。

最后说一句:多线程编程没有银弹。不要觉得用了原子操作就万事大吉,也不要觉得锁就是万恶之源。根据场景选对工具,才是真正的专家之道。


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