内存序与 atomic 深入:从语义到机器指令
本文要回答的问题
memory_order_relaxed和memory_order_seq_cst到底差在哪?不写会怎样?- acquire/release 是怎么实现"跨线程的顺序"的?和 happens-before 什么关系?
- x86 上为什么 acquire/release"免费"?换到 ARM 还免费吗?
- 编译器对内存序到底做了什么?编译器屏障和硬件屏障是一回事吗?
- 自旋锁为什么可能比 std::mutex 还慢?无锁编程的正确姿势是什么?
一、回顾:为什么需要内存序
高手层的《原子操作入门》讲过:std::atomic 保证单个操作的不可分割(不可中断),但不可分割 ≠ 有顺序。两个线程各自对原子变量 fetch_add,操作本身不会撕裂,但"谁先谁后"并没有被规定。更隐蔽的是:普通内存访问(非原子)在编译器重排和 CPU 乱序执行下,顺序也保不住。
内存序(memory order)就是给"原子操作 + 相邻的普通内存操作"之间规定可见性顺序的旋钮。C++ 标准定义了六种(consume 已事实弃用),从松到严:
| 内存序 | 语义 | 典型用途 |
|---|---|---|
relaxed | 只保证操作原子,不保证任何顺序 | 计数器、统计量(不在乎顺序) |
consume | 数据依赖序(实践中编译器大多升级为 acquire) | 理论上用于指针传递 |
acquire | 读操作:其后所有内存操作不得越过它 | 读锁、消费"已发布"的数据 |
release | 写操作:其前所有内存操作不得越过它 | 发布数据、释放锁 |
acq_rel | 同时具备(仅 RMW 操作可用) | CAS、exchange |
seq_cst | 最强:所有线程看到同一全局序 | 默认值;需要全局一致性时 |
本文不再重复这些定义,聚焦三件事:这些语义在机器上怎么落地、各架构差多少、实战中怎么选。
二、acquire/release:跨线程的"发布-消费"协议
理解内存序的核心是理解 acquire/release 配对。它是最常用、也最直观的一对:
std::atomic<bool> ready{false};
std::string data; // 普通变量
// 线程 A:生产数据,然后"发布"
data = "hello"; // (1) 普通写
ready.store(true, std::memory_order_release); // (2) release 写
// 线程 B:先"获取",再消费数据
while (!ready.load(std::memory_order_acquire)) {} // (3) acquire 读
std::cout << data << "\n"; // (4) 普通读——必然看到 "hello"关键保证:如果 (3) 读到了 (2) 写入的 true,那么 (1) 的写入对 (4) 的读取可见。release 的意思是"我这个写之前的所有内存写,都不允许被重排到这个写之后";acquire 的意思是"我这个读之后的所有内存读/写,都不允许被重排到这个读之前"。两者配对,形成一道单向屏障:A 在发布点之前写的所有东西,B 在获取点之后一定能看到。
这就是经典发布-消费(release-acquire)模式,也是互斥锁、无锁队列、单写单读管道的底层骨架。注意一个细节:B 可能先读到 false(自旋),但只要读到 true,数据就保证完整——release 屏障保证了 data 的写入"先于" ready 的写入被看见。
三、编译器做什么:指令重排与屏障
内存序有两条实现路径:编译器屏障和硬件屏障。理解它们的区别,才能看懂反汇编。
- 编译器屏障:告诉编译器"不要把这个操作两边的代码重排"。GCC/Clang 里是
asm volatile("" ::: "memory"),C++11 之后由原子操作自动带上。 - 硬件屏障:告诉 CPU"执行到这里时,冲刷 store buffer / 等待其他核确认"。x86 上是
mfence(或lock前缀、xchg),ARM 上是dmb/dsb。
std::atomic 的内存序参数同时控制两者。编译器负责不重排,CPU 负责强顺序。关键洞察:在某些架构上,硬件屏障可能完全不需要——因为架构本身的强序模型已经保证了顺序,编译器只需要不捣乱。
四、x86 实测:为什么 acquire/release"免费"
在 x86-64 上(服务器 AMD EPYC 7K62,GCC 10.2.1,-O2),把三种内存序的 store/load/fetch_add 各编译成独立函数反汇编(完整代码见 demos/cpp-expert/atomics-deep/):
# store:relaxed 与 release 完全相同
relaxed_store: movl $0x2a, 0x0(%rip) # 一条普通写
release_store: movl $0x2a, 0x0(%rip) # 一模一样
# store:seq_cst 用 xchg 提升
seqcst_store: mov $0x2a,%eax
xchg %eax, 0x0(%rip) # xchg 带隐式 lock,是全屏障
# load:三种内存序完全相同
relaxed_load: mov 0x0(%rip),%eax
acquire_load: mov 0x0(%rip),%eax
seqcst_load: mov 0x0(%rip),%eax
# fetch_add:两种完全相同
fetch_add_relaxed: lock addl $0x1, 0x0(%rip)
fetch_add_seqcst: lock addl $0x1, 0x0(%rip)这组反汇编把 x86 的脾气暴露无遗:
- load 天然 acquire、store 天然 release。x86 采用 TSO(Total Store Order,全存储排序) 内存模型:普通
mov的 load 不会越过先前的 load(强读序),store 按顺序到达其他核。所以 acquire 和 release 在 x86 上零成本——编译器只是不加屏障而已。 - seq_cst 的 store 需要
xchg。为什么?TSO 保证的是 store 顺序,但 seq_cst 还要求每个 load 也看到全局一致序。x86 的 store buffer 会让一个核先看到自己的写(store-load 重排),xchg带lock前缀,既是原子的又冲刷 store buffer,把 store 提升到全局序。 - RMW(fetch_add)必须
lock前缀。无论哪种内存序,读-改-写要原子完成,x86 都得锁总线/缓存行——lock addl。这也是为什么吞吐实测里 relaxed 并不比 seq_cst 快多少。
再看吞吐实测(4 线程各 5M 次 fetch_add,ns/op):
| 内存序 | 耗时 |
|---|---|
| relaxed | 10.92 |
| acq_rel | 11.98 |
| seq_cst | 11.88 |
差异不到 10%——与反汇编完全吻合:x86 上内存序不改变指令,瓶颈是 lock 前缀本身(所有核在共享变量上串行化)。
五、换到 ARM 就不同了:弱内存模型
x86 的"免费"是 TSO 的恩赐,ARM 和 RISC-V 是弱内存模型,同一段代码生成的指令完全不同:
# ARM64:release store 需要显式屏障
release_store: stlr w0, [x1] # STLR = store-release,硬件保证
acquire_load: ldar w0, [x1] # LDAR = load-acquire,硬件保证
seqcst_store: dmb ish # 全屏障
str w0, [x1]在 ARM 上,release/acquire 对应 stlr/ldar 指令(CPU 在硬件层面保证顺序),seq_cst 需要 dmb ish 全屏障。所以在 ARM 上 acquire/release 不是零成本,seq_cst 更贵。同一个 std::atomic 程序,在 x86 和 ARM 上的性能画像截然不同——这也是"内存序"这个抽象存在的意义:让同一份 C++ 代码在不同架构上都得到正确且尽可能高效的实现。
写可移植代码的通用建议:默认用 seq_cst(最简单、最不容易错);只有当你真的需要放松语义、且目标平台性能敏感时,才降级到 acquire/release;relaxed 留给纯计数场景。先正确,再优化。
六、实战:自旋锁为什么可能比 mutex 慢
内存序最经典的实战是自旋锁。用 atomic_flag 手写一个(demos/cpp-expert/atomics-deep/spinlock.cpp):
class SpinLock {
std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
void lock() {
while (flag.test_and_set(std::memory_order_acquire)) {} // 忙等
}
void unlock() {
flag.clear(std::memory_order_release);
}
};test_and_set(acquire) 是 RMW 操作:原子地把 flag 置 1 并返回旧值。返回 false 说明锁空闲(拿到);返回 true 说明已被占用,继续转圈。acquire/release 配对保证临界区内的内存操作正确同步。4 线程各 20 万次临界区实测:
| 锁 | ns/临界区 |
|---|---|
| atomic_flag 自旋锁 | 86.52 |
| std::mutex(futex) | 53.23 |
std::mutex 反而快。这个结果反直觉,但完全合理:互斥锁未竞争时走 futex 用户态快速路径——一个原子 CAS 就完成加锁,不进内核;而自旋锁的问题是忙等:4 个线程在同一个缓存行上反复 test_and_set,每个核都往共享缓存行写,导致缓存行颠簸(cache line ping-pong)——每次加锁/解锁都触发缓存一致性协议(MESI)的失效和重获,比省掉的那次系统调用更贵。
自旋锁的正确适用场景:临界区极短(纳秒级)且竞争很低(比如自旋 100 次内能拿到锁),这时省掉 futex 的内核往返有优势。竞争一旦上来,自旋锁立刻劣化——这也是标准库实现(如 pthread mutex)内置"先自旋再休眠"混合策略的原因。
七、无锁编程的正确姿势
掌握内存序后,很容易想"我要写无锁代码"。泼盆冷水:无锁编程是 C++ 里最容易出错、最难调试的领域之一,连 CPU 厂商的工程师都会搞错。入门者的正确姿势分三步:
- 先用锁。95% 的场景
std::mutex+ 锁保护就够,性能完全可接受(上面的实测也证明 mutex 不慢)。 - 确认真有性能问题(perf 分析确认锁是热点),再考虑无锁。
- 从教科书模式开始:单生产者单消费者环形队列(SPSC ring buffer)、引用计数、
std::atomic的 CAS 自旋。先用seq_cst跑对,再用 acquire/release 优化,每步都要在真实多核上压力测试。
两个常被误解的点:内存序只约束"顺序",不约束"数据竞争"——普通变量在多个线程同时读写仍然是未定义行为,必须用原子操作或锁;无锁 ≠ 无等待——CAS 自旋本质上还是忙等,只是不阻塞其他线程而已。无锁的真正价值是不死锁、不被调度器中断(适合实时场景),不是"免费更快"。

上图把内存序的落地路径画完整:语义层的六档旋钮,先由编译器翻译(不重排 + 插屏障),再落到具体架构的指令。x86 的 TSO 让 acquire/release 免费、只有 seq_cst store 用 xchg;ARM 的弱模型让每一档都要显式指令。最后落到运行期,真正的性能瓶颈是 lock 前缀的串行化与缓存行颠簸——这解释了实测里"内存序差异 <10%,而自旋锁 vs mutex 却差 40%"的怪象。
C 对照
| 维度 | C(C11 原子) | C++(C++11 原子) |
|---|---|---|
| 原子类型 | _Atomic / stdatomic.h | std::atomic<T>(模板) |
| 内存序 | memory_order_* 同名同义 | 相同六档,直接继承 C11 设计 |
| 屏障 | atomic_thread_fence | std::atomic_thread_fence |
| RMW | atomic_fetch_add | fetch_add 成员函数 |
| 无锁检测 | atomic_is_lock_free | is_always_lock_free 常量 |
C11 的原子与内存序设计和 C++11 是同一套(C++ 直接吸纳了 C11 的内存模型),概念完全互通。区别在于 C++ 把原子能力封装成了类型安全的 std::atomic<T> 模板,并提供 std::atomic_flag(保证无锁)等轻量原语。
一句话总结
内存序是"原子操作之间的可见性顺序"旋钮:acquire/release 配对实现发布-消费同步,x86 TSO 让它们免费、seq_cst store 才用 xchg,ARM 弱模型下每一档都要显式屏障指令;实测证明 x86 上内存序差异 <10%,真正贵的是 lock 前缀与缓存行颠簸——所以先用默认 seq_cst 写对,再按需放松。
上一篇:C++ concepts 概念 下一篇:性能剖析 C++ 程序