5、原子操作与内存序:std::atomic基础、原子类型与操作、内存序模型(relaxed/acquire/release/seq_cst)、无锁编程入门
多线程编程里,最让人头疼的是什么?
我个人觉得,不是死锁,也不是线程创建销毁的开销。而是——数据竞争。你明明加了个锁,程序还是偶尔崩溃;你明明觉得逻辑没问题,跑着跑着就出诡异的结果。
嗯,这时候你需要的,就是原子操作。
5.1 为什么需要原子操作?
先看个经典例子:
int counter = 0;
// 线程A
counter++;
// 线程B
counter++;
两个线程同时执行 counter++,你觉得结果一定是2吗?
不一定。因为 counter++ 在底层是三条指令:读、加、写。线程A读到0,还没来得及写回去,线程B也读到0,然后各自加1写回——结果变成了1。
这就是数据竞争。锁能解决,但锁太重了。对于这种简单的加减操作,我们有更好的选择——原子操作。
核心概念:原子操作是不可分割的操作。要么全部执行完,要么完全不执行。中间不会被其他线程打断。
5.2 std::atomic 基础
C++11 引入了 std::atomic,让我们可以轻松定义原子变量。
#include <atomic>
std::atomic<int> counter(0);
// 线程A
counter.fetch_add(1);
// 线程B
counter.fetch_add(1);
这样,counter 最终一定是2。为什么?因为 fetch_add 是原子的,底层会使用 CPU 提供的 LOCK 前缀指令或 CAS(Compare-And-Swap)指令。
我在项目中遇到过一个问题:用 std::atomic<bool> 做标志位,控制线程的启停。一开始用普通 bool 加 volatile,结果在高并发下偶尔会漏掉状态变化。换成 std::atomic<bool> 后,问题彻底解决。
小提示:std::atomic 是模板类,支持所有基础类型(int、long、bool、指针等)。C++20 还支持了 std::atomic<std::shared_ptr>。
5.3 原子类型与操作
常用的原子操作有哪些?我整理了一张表:
| 操作 | 说明 | 示例 |
|---|---|---|
load() |
原子读取 | int val = counter.load(); |
store() |
原子写入 | counter.store(10); |
exchange() |
原子交换 | int old = counter.exchange(5); |
compare_exchange_weak() |
CAS(弱版本) | int expected = 0; counter.compare_exchange_weak(expected, 1); |
compare_exchange_strong() |
CAS(强版本) | int expected = 0; counter.compare_exchange_strong(expected, 1); |
fetch_add() |
原子加 | counter.fetch_add(1); |
fetch_sub() |
原子减 | counter.fetch_sub(1); |
这里要特别说一下 compare_exchange_weak 和 compare_exchange_strong 的区别。
弱版本在某些平台上可能会伪失败(spurious failure),即明明值相等,却返回 false。强版本保证不会伪失败。但弱版本在某些架构上性能更好。
我个人的习惯是:在循环中使用弱版本,因为循环会重试;在非循环场景用强版本,避免意外失败。
// 弱版本 + 循环
int expected = 0;
while (!counter.compare_exchange_weak(expected, 1)) {
expected = 0; // 重置 expected
}
// 强版本
int expected = 0;
if (counter.compare_exchange_strong(expected, 1)) {
// 成功
}
5.4 内存序模型
原子操作还有一个重要的参数——内存序(memory order)。
说白了,内存序控制的是:这个原子操作对其他线程的可见性。
C++ 定义了六种内存序,但常用的就四种:
- memory_order_relaxed:最宽松,只保证原子性,不保证顺序
- memory_order_acquire:读取操作,保证后续读写不会重排到它前面
- memory_order_release:写入操作,保证前面的读写不会重排到它后面
- memory_order_seq_cst:最严格,全局顺序一致(默认值)
你想想看,为什么需要这些?因为 CPU 和编译器会为了性能对指令进行重排。单线程下没问题,多线程下就可能出乱子。
5.4.1 Relaxed 模型
最轻量,但也是最危险的。只保证原子性,不保证任何顺序。
std::atomic<int> x(0);
std::atomic<int> y(0);
// 线程A
x.store(1, std::memory_order_relaxed);
y.store(1, std::memory_order_relaxed);
// 线程B
int b = y.load(std::memory_order_relaxed);
int a = x.load(std::memory_order_relaxed);
// 可能出现 b==1 且 a==0 的情况
为什么会这样?因为 relaxed 不阻止重排。线程A可能先写 y 再写 x,线程B先读 y 再读 x,结果就是看到 y 更新了但 x 没更新。
警告:relaxed 只适合做计数器这种不依赖顺序的场景。我曾经在项目里用它做标志位,结果线程退出逻辑出了问题——因为标志位虽然原子了,但其他数据的可见性没保证。
5.4.2 Acquire-Release 模型
这是最常用的配对模型。release 用于写入,acquire 用于读取。
std::atomic<int> flag(0);
int data = 0;
// 线程A(生产者)
data = 42;
flag.store(1, std::memory_order_release);
// 线程B(消费者)
while (flag.load(std::memory_order_acquire) == 0) {
// 等待
}
// 此时 data 一定是 42
release 保证:写入 flag 之前的所有写操作,对其他线程可见。
acquire 保证:读取 flag 之后的所有读操作,能看到 release 之前写入的值。
说白了,这就是一个「同步点」。我在做消息队列时经常用这个模式——生产者准备好数据后 release 一个标志,消费者 acquire 到这个标志后,就能安全地读取数据。
5.4.3 Seq-Cst 模型
这是默认的内存序,也是最严格的。所有线程看到的操作顺序完全一致。
std::atomic<int> x(0);
std::atomic<int> y(0);
// 线程A
x.store(1, std::memory_order_seq_cst);
// 线程B
y.store(1, std::memory_order_seq_cst);
// 线程C
int a = x.load(std::memory_order_seq_cst);
int b = y.load(std::memory_order_seq_cst);
// 线程D
int c = y.load(std::memory_order_seq_cst);
int d = x.load(std::memory_order_seq_cst);
在 seq_cst 下,所有线程看到的操作顺序是全局一致的。不会出现线程C看到 x=1、y=0,而线程D看到 y=1、x=0 的情况。
代价呢?性能开销最大。因为 CPU 需要做更多的同步工作。
我个人的建议是:默认用 seq_cst,等性能瓶颈出现时,再考虑优化成 acquire-release 或 relaxed。不要一开始就追求极致性能,正确性永远是第一位的。
5.5 无锁编程入门
无锁编程(Lock-Free Programming),说白了就是不用互斥锁,只用原子操作来实现线程安全的数据结构。
为什么要无锁?
- 避免死锁
- 避免线程阻塞和唤醒的开销
- 在某些场景下性能更高
但无锁编程非常难。我刚开始学的时候,写一个无锁队列,调试了整整一周才跑对。
来看一个最简单的无锁栈:
template<typename T>
class LockFreeStack {
private:
struct Node {
T data;
Node* next;
};
std::atomic<Node*> head{nullptr};
public:
void push(const T& value) {
Node* new_node = new Node{value, nullptr};
new_node->next = head.load(std::memory_order_relaxed);
while (!head.compare_exchange_weak(
new_node->next,
new_node,
std::memory_order_release,
std::memory_order_relaxed)) {
// 循环直到成功
}
}
bool pop(T& value) {
Node* old_head = head.load(std::memory_order_relaxed);
while (old_head != nullptr &&
!head.compare_exchange_weak(
old_head,
old_head->next,
std::memory_order_acquire,
std::memory_order_relaxed)) {
// 循环直到成功
}
if (old_head == nullptr) return false;
value = old_head->data;
delete old_head;
return true;
}
};
这个实现有个问题——内存泄漏。如果线程A在 pop 时删除了节点,但线程B还在用这个节点,就会出问题。这就是无锁编程中的ABA 问题。
避坑指南:我曾经在无锁队列里踩过 ABA 的坑。解决方案是用带标记的指针(tagged pointer)或 hazard pointer。C++20 的 std::atomic_ref 和 std::atomic<std::shared_ptr> 也能帮上忙。
5.6 知识体系总览
说了这么多,来张图总结一下:
5.7 总结
原子操作和内存序,是多线程编程的基石。我做了这么多年并发编程,最大的体会就是:先保证正确,再追求性能。
几个要点再强调一下:
- 能用
std::atomic就别用volatile,后者不保证原子性 - 默认用
seq_cst,性能瓶颈时再优化 - acquire-release 是最常用的同步模型,理解它就能解决大部分问题
- 无锁编程很强大,但也很危险。没有充分测试前,别上生产环境
嗯,原子操作就讲到这里。记住一句话:原子操作不是银弹,但它是你理解并发编程的必经之路。