13、原子操作与内存模型:std::atomic 详解、内存序的六种类型、无锁编程入门
多线程编程里,最让人头疼的是什么?
我个人觉得,不是死锁,也不是线程创建销毁的开销。而是那些莫名其妙的数据竞争——你明明加了锁,程序还是偶尔崩溃;你换个编译器优化等级,逻辑就全乱了。
嗯,这背后其实涉及两个核心问题:原子性和内存序。C++11 引入的 std::atomic 和 memory_order,就是专门来收拾这个烂摊子的。
13.1 为什么需要原子操作?
先看一个经典例子:两个线程同时对一个 int 变量做 ++counter。
你可能会说:“不就是加1吗?CPU 一条指令就搞定了。” 但真相是,counter++ 在底层至少拆成三步:读→改→写。线程 A 读到 10,线程 B 也读到 10,各自加完写回 11——明明加了两次,结果却只加了 1。
这就是数据竞争。用互斥锁能解决,但锁的开销不小。对于简单的整数操作,原子变量是更好的选择。
核心概念:原子操作是不可分割的操作。要么全部执行完,要么一点没执行。中间不会被其他线程打断。
13.2 std::atomic 的基本用法
std::atomic 是一个模板类。最常用的特化是整数类型和指针类型。
#include <atomic>
#include <thread>
#include <iostream>
std::atomic<int> counter(0);
void worker() {
for (int i = 0; i < 10000; ++i) {
counter.fetch_add(1); // 原子加1
// 等价于 counter += 1; 但 fetch_add 更灵活
}
}
int main() {
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
std::cout << counter.load() << std::endl; // 输出 20000
return 0;
}
注意几个常用操作:
load():原子读取store():原子写入exchange():原子交换compare_exchange_weak/strong():CAS(比较并交换)
我的习惯:能用 fetch_add 就别用 operator+=。虽然效果一样,但 fetch_add 可以指定内存序,语义更清晰。
13.3 内存序(memory_order)—— 六种类型详解
原子操作只是解决了“不可分割”的问题。但多核 CPU 还有另一个大坑:指令重排。
编译器、CPU 都可能为了优化而打乱指令顺序。单线程下没问题,多线程下就出大事了。
内存序就是用来约束重排行为的。C++11 定义了六种内存序,分成三个梯队。
| 内存序 | 类型 | 重排约束 | 性能 |
|---|---|---|---|
memory_order_relaxed |
松散 | 无约束 | 最快 |
memory_order_consume |
消费 | 数据依赖 | 较快 |
memory_order_acquire |
获取 | 禁止之后读操作重排到前面 | 中等 |
memory_order_release |
释放 | 禁止之前写操作重排到后面 | 中等 |
memory_order_acq_rel |
获取释放 | acquire + release | 较慢 |
memory_order_seq_cst |
顺序一致 | 全局顺序一致 | 最慢 |
默认是 memory_order_seq_cst,最安全,但性能也最差。
13.3.1 relaxed:最自由,也最危险
memory_order_relaxed 只保证原子性,不保证任何顺序。不同线程看到的变量修改顺序可能完全不同。
std::atomic<int> x(0), y(0);
// 线程1
x.store(1, std::memory_order_relaxed);
y.store(1, std::memory_order_relaxed);
// 线程2
while (y.load(std::memory_order_relaxed) != 1) {}
assert(x.load(std::memory_order_relaxed) == 1); // 可能失败!
线程2 可能看到 y 变成 1 了,但 x 还是 0。因为 relaxed 不阻止重排。
我曾经在一个计数器场景里用了 relaxed,想着“反正只是计数,顺序无所谓”。结果在 ARM 平台上跑,计数结果偶尔偏小。查了两天才发现是 relaxed 导致缓存同步不及时。后来换成 acquire/release 就稳了。
13.3.2 acquire / release:最常用的配对
这两个是成对出现的。release 写,acquire 读。形成一种同步关系:
- 写线程 release 之前的所有写操作,对读线程 acquire 之后的所有读操作可见。
std::atomic<bool> ready(false);
int data = 0;
// 生产者线程
data = 42;
ready.store(true, std::memory_order_release);
// 消费者线程
while (!ready.load(std::memory_order_acquire)) {}
assert(data == 42); // 一定成功!
release 保证了 data = 42 不会被重排到 store 之后。acquire 保证了 load 之后的操作不会重排到前面。
13.3.3 seq_cst:全局一致,但慢
顺序一致性是所有线程看到的修改顺序完全一致。就像所有操作都在一个全局时钟下执行。
理解起来最简单,但性能开销最大。因为 CPU 需要频繁同步缓存。
我的建议:除非你明确知道自己在做什么,否则先用 seq_cst 把功能跑通。性能瓶颈出现后,再逐个换成更宽松的内存序。不要一开始就追求极致性能。
13.4 无锁编程入门
无锁编程,说白了就是不用互斥锁,只用原子操作来保证线程安全。
好处很明显:没有锁竞争,不会死锁,性能通常更好。但坏处也很明显:太难写对了。
13.4.1 一个简单的无锁栈
用 CAS(compare_exchange_weak)实现一个无锁栈的 push:
template<typename T>
class LockFreeStack {
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);
// CAS:如果 head 还是 old_next,就替换成 new_node
while (!head.compare_exchange_weak(
new_node->next,
new_node,
std::memory_order_release,
std::memory_order_relaxed
)) {
// 失败说明其他线程改了 head,重试
}
}
};
这里的关键是 compare_exchange_weak。它检查 head 是否等于 new_node->next,如果是就替换,否则更新 new_node->next 为最新的 head,然后重试。
注意:compare_exchange_weak 可能“伪失败”(即使值相等也返回 false),所以必须放在循环里。compare_exchange_strong 没有伪失败,但性能稍差。
13.4.2 ABA 问题
无锁编程有个经典陷阱:ABA 问题。
线程1 读到 head 指向节点 A,准备 CAS。但线程2 把 A 弹出了,又 push 了一个新节点,恰好地址也是 A(被回收重用)。线程1 的 CAS 发现 head 还是 A,就成功了——但此时 A 的内容已经变了。
解决办法:用带标记的指针(tagged pointer),每次操作都递增一个版本号。
我曾经在一个无锁队列里踩过 ABA 的坑。线上跑了三天才复现一次崩溃。后来加了 64 位的版本号(用 double-word CAS),再也没出过问题。嗯,无锁编程的调试成本真的很高。
13.5 知识体系总览
下面这张图总结了本章的核心脉络:
13.6 避坑指南与最佳实践
最后,分享几个我踩过的坑:
- 别滥用 relaxed:它只保证原子性,不保证顺序。用在计数器上没问题,但用在标志位上很容易出 bug。
- CAS 循环要小心:如果 CAS 一直失败(高竞争场景),CPU 会空转。可以考虑加退避策略(比如 yield 或 sleep)。
- 原子变量不是万能的:对于复杂数据结构(比如链表、树),无锁实现的复杂度远超想象。能用锁就用锁。
- 注意平台差异:x86 的强内存模型让很多 bug 难以复现。换到 ARM 或 PowerPC 上,问题就暴露了。我建议在弱内存序的平台上多测试。
一句话总结:std::atomic 给了你一把锋利的刀,memory_order 决定了你怎么用这把刀。用好了,性能飞升;用不好,bug 缠身。
公众号:蓝海资料掘金营,微信 deep3321