13、原子操作与内存模型:std::atomic 详解、内存序的六种类型、无锁编程入门

多线程编程里,最让人头疼的是什么?

我个人觉得,不是死锁,也不是线程创建销毁的开销。而是那些莫名其妙的数据竞争——你明明加了锁,程序还是偶尔崩溃;你换个编译器优化等级,逻辑就全乱了。

嗯,这背后其实涉及两个核心问题:原子性内存序。C++11 引入的 std::atomicmemory_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 知识体系总览

下面这张图总结了本章的核心脉络:

原子操作与内存模型知识体系 std::atomic 原子操作类型 load / store fetch_add / exchange CAS (compare_exchange) 六种内存序 relaxed(松散) acquire / release seq_cst(顺序一致) 无锁编程 无锁栈/队列 ABA 问题 核心原则 原子性保证基础 · 内存序控制可见性 · 无锁编程追求极致性能 ⚠ 无锁编程调试成本高,建议先用锁实现,性能瓶颈时再优化

13.6 避坑指南与最佳实践

最后,分享几个我踩过的坑:

  1. 别滥用 relaxed:它只保证原子性,不保证顺序。用在计数器上没问题,但用在标志位上很容易出 bug。
  2. CAS 循环要小心:如果 CAS 一直失败(高竞争场景),CPU 会空转。可以考虑加退避策略(比如 yield 或 sleep)。
  3. 原子变量不是万能的:对于复杂数据结构(比如链表、树),无锁实现的复杂度远超想象。能用锁就用锁。
  4. 注意平台差异:x86 的强内存模型让很多 bug 难以复现。换到 ARM 或 PowerPC 上,问题就暴露了。我建议在弱内存序的平台上多测试。

一句话总结std::atomic 给了你一把锋利的刀,memory_order 决定了你怎么用这把刀。用好了,性能飞升;用不好,bug 缠身。


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