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,最安全但最慢。如果你对性能有极致要求,可以考虑用 relaxed 或 acquire/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);
}
这里 release 和 acquire 配对使用,形成了一种「同步关系」。线程1在 release 之前的所有写入,线程2在 acquire 之后都能看到。
四、线程安全队列实现——把理论串起来
理论说完了,咱们动手写一个真正的线程安全队列。这个实现我在好几个项目里都用过,算是比较成熟的模板。
#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 限制。- 移动语义:
push和pop都用std::move,避免不必要的拷贝。
pop() 是阻塞的。如果队列永远不会再有数据,线程会一直卡在 wait() 里。实际项目中,我通常会加一个「停止」标志,或者用超时等待。比如 wait_for() 或 wait_until()。
五、知识体系总览
下面这张图把本章的核心知识点串起来了。你想想看,从最底层的 CPU 原子指令,到上层的线程安全容器,每一层都有它的职责。
你看,从下往上,每一层都是对上一层的补充和优化。互斥锁解决互斥问题,条件变量解决等待问题,原子操作解决轻量同步问题,内存序解决顺序一致性问题。它们不是互相替代的关系,而是各司其职。
最后说一句:多线程编程没有银弹。不要觉得用了原子操作就万事大吉,也不要觉得锁就是万恶之源。根据场景选对工具,才是真正的专家之道。
公众号:蓝海资料掘金营,微信deep3321