☰
C++ 原子操作的内存序:为什么需要它
2026/10/9 5:01:06 网站建设 项目流程

本文深入解析std::atomic的三大核心属性:原子性、可见性与顺序性,强调仅用atomic不等于线程安全。内存序是解决可见性与顺序性的关键,通过happens-before语义屏蔽编译器与CPU重排差异。六种内存序按强度分层,从relaxed到seq_cst,需根据场景选择:默认用seq_cst保证跨平台安全;发布-订阅用release/acquire;计数器可用relaxed。volatile不能替代atomic。硬件层面,x86的TSO模型使release/acquire开销小,而ARM等弱模型需屏障指令,导致同一代码在不同平台行为迥异。核心观点:内存序不是性能开关,而是确保多线程间状态传递正确性的语言级保障。

一、问题起点:原子性不等于可见性,也不等于顺序性

std::atomic解决三件不同的事:

  1. 原子性:读-改-写不会被拆开,不会出现“撕裂值”。

  2. 可见性:一个线程的写入,什么时候能被另一个线程看到。

  3. 顺序性:多个内存操作之间的先后关系,其他线程是否也能观察到同样顺序。

很多人以为“用了std::atomic就线程安全了”,这是错的。原子性只解决第 1 点。内存序解决的是第 2、3 点。


二、为什么需要内存序:两层重排

没有内存序约束时,两件事会重排:

1. 编译器重排

data = 42; ready = 1;

编译器可以变成:

ready = 1; data = 42;

只要单线程执行结果不变,编译器就有权这么做。多线程下,另一个线程可能先看到ready == 1,再读data,得到 0。

2. CPU 重排

现代 CPU 是流水线、乱序执行、写缓冲(store buffer)的。

x86 是 TSO(Total Store Order):

  • Load 不重排 Load

  • Store 不重排 Store

  • 只允许 Store → Load 重排

ARM / RISC-V / POWER 是弱内存模型:

  • Load-Load、Load-Store、Store-Store、Store-Load 都可能重排

所以同一份 C++ 代码:

  • x86 上“碰巧对”

  • ARM 上直接出数据竞争

内存序就是用来屏蔽这些差异的语言级抽象。


三、happens-before:内存序的真正语义

C++不让你去想“CPU 先执行了哪条指令”,而是定义:

如果 Ahappens-before​ B,那么 A 的副作用对执行 B 的线程一定可见。

happens-before 来自:

  • 同一线程内的程序顺序

  • release写 与 同一原子变量上的acquire读 之间的 synchronizes-with

  • mutex::unlock→mutex::lock

  • thread启动、join、条件变量通知等

内存序的作用,就是告诉编译器和 CPU:

“在这一点前后,哪些普通内存访问不许跨过来。”


四、六种内存序,按强度分层

1.memory_order_relaxed

只保证原子性,不保证顺序。

counter.fetch_add(1, std::memory_order_relaxed);

适用:

  • 引用计数自增

  • 统计计数器

  • 不依赖“其他变量也同时可见”的场景

不适用:

  • 用原子变量做“数据发布”

  • 生产者-消费者握手

2.memory_order_acquire(读端)

while (!ready.load(std::memory_order_acquire)) {} // 后面读 data 一定看到生产者释放前的内容 int x = data;

语义:

  • 本次 load 之后的读写,不能被重排到本次 load 之前

  • 如果读到了别人 release 写入的值,就建立 synchronizes-with

3.memory_order_release(写端)

data = 42; ready.store(true, std::memory_order_release);

语义:

  • 本次 store 之前的读写,不能被重排到本次 store 之后

  • 消费者若 acquire 到这个值,就能看到data = 42

经典发布模型:

// 生产者 payload = ...; flag.store(true, release); // 消费者 while (!flag.load(acquire)) ; use(payload); // 安全

4.memory_order_acq_rel

用于 read-modify-write:

val.fetch_add(1, std::memory_order_acq_rel);

既是 acquire 又是 release。常用于:

  • 自旋锁

  • 无锁栈/队列里的 CAS/RMW

5.memory_order_seq_cst

默认内存序,最强:

  • 有 acquire/release 语义

  • 所有 seq_cst 操作之间存在一个全局总顺序

  • 所有线程看到这些原子操作的顺序完全一致

x.store(1, std::memory_order_seq_cst); y.store(1, std::memory_order_seq_cst);

其他线程不会看到“矛盾的全序视角”。

代价:

  • x86 上可能生成mfence或xchg

  • ARM 上需要dmb ish

  • 多核高竞争时性能明显下降

6.memory_order_consume

基于数据依赖的弱同步。C++26起弃用,主流编译器直接按 acquire 处理,实际工程基本不用。


五、为什么不能用“volatile”代替

volatile只阻止编译器优化,不保证:

  • CPU 不重排

  • 多核可见顺序

  • 与其他变量的顺序关系

volatile不是并发原语。std::atomic+ 正确内存序才是。


六、硬件映射:同一份 C++,不同汇编

x86-64

ready.store(true, memory_order_release); // 通常就是 mov [ready], 1 // 不需要 mfence flag.load(memory_order_acquire); // 通常就是 mov rax, [flag]

x86 TSO 已经隐含了 StoreStore / LoadLoad 顺序,所以 release/acquire 在 x86 上“几乎免费”,主要约束编译器。

ready.store(true, memory_order_seq_cst); // 可能生成: // mov [ready], 1 // mfence // 或 xchg [ready], reg

ARM64

ready.store(true, release); // stlr flag.load(acquire); // ldar seq_cst store; // dmb ish + store

ARM 允许大量重排,所以 release/acquire 会真正变成屏障指令。

这就是“x86 上没问题,ARM 上一炸再炸”的根本原因。


七、典型错误:消息传递用 relaxed

// 错误 data = 42; ready.store(true, memory_order_relaxed); // 另一线程 if (ready.load(memory_order_relaxed)) { assert(data == 42); // 可能失败 }

relaxed 不建立 happens-before。ready == true被看到时,data的写入可能还没传播出去。

正确写法:

data = 42; ready.store(true, memory_order_release); if (ready.load(memory_order_acquire)) { assert(data == 42); // 安全 }

八、选型原则

  1. 默认用 seq_cst​

    正确、可推理、跨平台安全。

  2. 发布数据用 release / acquire​

    无锁队列、SPSC 管道、标志位握手的首选。

  3. 独立计数器用 relaxed​

    不发布其他状态,只求自增原子性。

  4. 不要过早优化内存序​

    大多数无锁 bug 来自“我以为 relaxed 够用”。

  5. 多个变量要一起可见,用 mutex 或把数据放进指针后 release 指针​

    原子变量只能保证“这一个对象”的原子性,不能自动保护一堆普通变量。


九、一句话本质

内存序不是“让程序更快的开关”,而是告诉编译器和 CPU:

“哪些内存操作必须像单线程里那样,保持先后关系和跨线程可见性。”

没有内存序,原子变量只是“不会撕裂的变量”;

有了内存序,它才成为线程之间传递状态的通道。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询