C/C++ 现代语言与底层 · 第 十 篇

内存序与原子:seq_cst 在 x86 上免费吗

2026-10-1418,075 字40 张配图

本文由@溯光 出品 站点:https://yyy3d.online/blog/ | 本系列:https://yyy3d.online/blog/#topic-cpp-language-foundations 题图 「免费」要分 load、store、RMW 三类操作来问:前两类在 x86 上免费,store 不免费 题图 「免费」要分 load、store、RMW 三类操作来问:前两类在 x86 上免费,store 不免费 @ 本系列绘制


本系列已发表篇目(全系列共 12 篇,分四波连载):


背景与边界

好了,上一篇算的是"语言替你留下的痕迹"里异常与类型信息那两笔。这一篇算第三笔,也是最少被量化的一笔:内存序。

std::atomic 的默认内存序是 seq_cst(sequentially consistent,顺序一致)。它承诺的是一个单一全序——所有 seq_cst 操作在全局有一个一致的先后。这句承诺的代价是多少?在 x86 上,流传最广的回答是"免费",依据是那句被反复引用的表述:强序机器上 release-acquire 是自动的,不产生额外指令。

这一篇要把「免费」这个判断按操作类型拆开,因为答案不是一句话:有的操作确实免费,有的不免费。

0.1 一句话结论先放这里

  • 免费与否取决于操作类型。按本机实测:load 免费(各档都是单条 mov)、RMW 免费(fetch_add 与 CAS 五个内存序档位生成的指令逐字节相同),但 store 不免费——seq_cst store 在 x86 上是 xchg(隐含锁),实测约 6 至 8 倍。
  • 「x86 是强内存模型」这句话只对了一半。它放松的恰恰是 StoreLoad:本机千万轮 store-buffering 里 relaxed 稳定命中 2.62%(MSVC)/ 3.07%(GCC),而 seq_cst 命中 0 次。
  • 两套工具链的原子操作里没有一条 mfence。而 seq_cst fence 也不是 mfence——GCC 生成 lock or QWORD PTR [rsp],0x0,MSVC 生成 lock inc DWORD PTR [rsp+8],后者改写的是调用者的影子空间。

0.2 实测环境

项 值
MSVC cl / dumpbin 19.44.35228,/std:c++17 /O2 /EHsc
GCC MSYS2 ucrt64 g++ 16.2.0 + binutils objdump 2.47,-std=c++17 -O2 -pthread
平台 Windows x86-64
计时 被测进程内 std::chrono::steady_clock,每档多轮
计数口径 反汇编表只统计核心指令,剔除对齐填充 nop 与函数尾 ret
负载 单线程 5,000 万次;竞争 2,000 万次总操作;store-buffering 1,000 万轮

0.3 本篇不涉及什么(边界声明)

  • 不做 ARM / PowerPC 的本机实测。本机是 x86-64,ARM 的 ldar / stlr / dmb 只引官方表述作对照。"免费"是机器给的,不是语言给的,这一点靠 ARM 的表述来突显,而不是靠本机读数。
  • 不做硬件性能计数器的实测。本机无 perf、无 VTune。所以本篇给不出 cache-miss 事件计数,缓存行争抢只能从端到端时间间接观察。这条限制也会直接影响下一篇(ep11 缓存与布局)。
  • 不做 TSan 与形式化模型的机器检验。本机无 TSan、无 herd7。StoreLoad 重排只做统计式实证(千万轮命中率),不声称复现了 x86-TSO 的形式化模型。
  • 不涉及 atomic_ref 与 atomic<shared_ptr> 的实现细节,只引其语义。
  • 不给"无锁一定更快"或"处处 seq_cst"的立场建议,只给判据与读数。

0.4 按需跳读

本篇 11 章,三条路线:

  • 只想知道"我该写哪个内存序":读 §一(三个词)、§四(时间侧三类操作)、§九(判据)——三章,约 2,600 字。
  • 正在写无锁结构:加读 §五(StoreLoad 重排的实证)与 §八(竞争下的账)。这两章的读数决定你的取舍。
  • 想学方法(怎么自己看):读 §三(全档位反汇编表)、§六(fence 的真相)、§七(volatile 的边界)、§十(证据形态与复现)。

0.5 这一篇与系列其他篇的接续

  • 接 ep09:两篇合起来是"语言机制留下的痕迹"三笔账——异常、类型信息、内存序。ep09 量的是"语言替你留了什么",本篇量的是"语言向你承诺了什么、机器实际给了什么"。
  • 接 ep01:atomic<T> 的大小与对齐(§八)接着 ep01 的布局话题——实测除 atomic_flag 外无额外包装开销。
  • 接 ep05:本篇 §六 要观察编译器重排,与 ep05 的 UB / 优化器边界同族——编译器屏障与硬件屏障是两件事。

0.6 为什么这一篇要按"操作类型"拆

「seq_cst 在 x86 上免费吗」这个问题,多数文章给的是一个单词的答案。但把这个答案按操作拆开之后,情况是这样的:

操作类型 典型指令 x86 上 seq_cst 的额外代价
load mov 无(各档同指令)
store(relaxed / release) mov —
store(seq_cst) xchg 有(实测约 6 至 8 倍)
fetch_add / CAS lock xadd / lock cmpxchg 无(各档同指令)

原因不难理解:load 需要的 acquire 语义与 store 需要的 release 语义,x86 的硬件本来就免费给你(它不允许 LoadLoad 与 StoreStore 重排);lock 前缀本身就带全序,所以 RMW 的各档没有差别。唯一漏网的是 seq_cst 的 store——它需要的"全局可见顺序"在 x86 上要靠一条带锁的指令来实现。

这正是本篇要讲清楚的那一笔账。

「免费」要分 load、store、RMW 三类操作来问:前两类在 x86 上免费,store 不免费
题图1 「免费」要分 load、store、RMW 三类操作来问:前两类在 x86 上免费,store 不免费@ 本系列绘制

0.7 证据形态与读法

本篇的证据有三类,读的时候要分清它们各自能证什么。

第一类是标准与厂商规范:C++ 标准的两页([intro.races]、[atomics.order])、Intel SDM 的第 9 章、Arm 的官方表述。它们能证明"承诺了什么""硬件允许什么",不能证明"某个操作在你机器上花多少时间"。

第二类是本机实测:反汇编、指令计数、单线程与竞争读数、重排命中率、布局转储。它们能证明"在这套工具链的这个版本上,代价是这个数",不能证明"别的架构也一样"。

第三类最特殊:本机拿不到的那部分。ARM 的屏障行为、PMU 事件计数、TSan 的检测结果都属于这一类。对这一类,本篇只引官方表述并明确声明没有实测,不做推断性结论。

三类证据混着读,最容易犯两个错:

  • 把一家的读数当通例。本篇里 MSVC 与 GCC 在反汇编层面高度一致(这本身是个发现),但在读数上有量级相同的抖动差异,把任何一家的单个数字当结论都会过读。
  • 把"可能"当"经常"。StoreLoad 重排确实"可能"发生,但实测命中率是 2.6% 至 3.1%(relaxed),既不是零也不是常态。报"可能"不报频率,读者就无法判断该不该为它花钱。

下面这张表把本篇的六处反直觉先集中放一次,方便回头对照(细节在各章):

# 反直觉 小节
1 seq_cst 的免费与否取决于是 load、store 还是 RMW §四
2 两套工具链的原子操作没有一条 mfence §三、§六
3 seq_cst fence 不是 mfence,MSVC 版还改写调用者的影子空间 §六
4 volatile store 与 relaxed atomic store 逐字节相同 §七
5 x86 上 relaxed 稳定复现 StoreLoad 重排(2.6% 至 3.1%) §五
6 atomic_flag 的 ABI 两家不同(4 字节 / 1 字节) §八

一、三个词:happens-before、synchronizes-with、单一全序

言归正传,先把标准里的三个词说清楚,后面的读数才有地方落。

1.1 happens-before:标准唯一认可的"先后"

OK,C++ 标准不谈"指令的执行顺序",它谈的是 happens-before 这个关系:

An evaluation A happens before an evaluation B (or, equivalently, B happens after A) if either — A is sequenced before B, or A synchronizes with B, or A happens before X and X happens before B.

—— C++ draft [intro.races]

拆开看,只有三条构造路径:同一线程内的顺序(sequenced before)、跨线程的同步(synchronizes with)、以及传递闭包。第三条是前两条能不能串起来的关键——一次同步不只是让两个操作有先后,它会把两个线程里各自之前的所有操作都连上。

1.2 synchronizes-with:release 与 acquire 的配对

说白了,这对配对关系值得在动手写代码前先想清楚:release 只负责把本线程之前的一切托付出去,acquire 只负责把对方托付过来的一切接住。两句话各说一半,合起来才是一份完整的保证;只写其中一半,编译器不会报错,运行期也不会立刻出事,但那份保证其实并不存在。

那条"跨线程的同步"来自哪?标准给了原子操作这一条最常用的来源:

Certain library calls synchronize with other library calls performed by another thread. For example, an atomic store-release synchronizes with a load-acquire that takes its value from the store.

—— 同上

这一句就是"发布-订阅"模式的全部机制:线程 A 用 release 写一个标志,线程 B 用 acquire 读到同一个标志,于是 A 在 release 之前写的一切,对 B 在 acquire 之后可见。

注意配对关系:release 与 acquire 是成对的,单独一个没有意义。这与 ep09 里 noexcept 的语义承诺同构——承诺本身不产生效果,配对使用才产生 happens-before。

happens-before 的三条构造路径与 synchronizes-with 的配对:release 写、acquire 读,把两个线程各自之前的操作连起来
图 10-12 happens-before 的三条构造路径与 synchronizes-with 的配对:release 写、acquire 读,把两个线程各自之前的操作连起来@ 本系列绘制

1.3 单一全序 S:seq_cst 独有,但有一条限制

好了,seq_cst 比 acquire/release 多给的东西,是一个跨越所有 seq_cst 操作的单一全序:

There is a single total order S on all memory_order::seq_cst operations, including fences, that satisfies the following constraints. First, if A and B are memory_order::seq_cst operations and A strongly happens before B, then A precedes B in S.

—— C++ draft [atomics.order]

这个全序是 seq_cst 的全部价值所在:它让所有线程对"这些操作谁先谁后"有同一个答案。acquire/release 给不了这个——两个线程可能对同一对操作的先后有不同看法。

但同页还有一条限制,本篇要单独讲:

memory_order::seq_cst ensures sequential consistency only for a program that is free of data races and uses exclusively memory_order::seq_cst atomic operations. Any use of weaker ordering will invalidate this guarantee unless extreme care is used.

—— 同上

"only for ... uses exclusively seq_cst" ——这句把"我用了 seq_cst 就安全了"这个想法直接否掉了。你的程序里只要有一处弱内存序,全局的顺序一致承诺就不再成立。这条限制解释了为什么工程里"混用内存序"是无锁代码最容易出错的地方。

单一全序 S 与它的限制:全序让所有线程对先后有同一答案,但混用弱序会让这个承诺失效
图 10-23 单一全序 S 与它的限制:全序让所有线程对先后有同一答案,但混用弱序会让这个承诺失效@ 本系列绘制

1.4 修改顺序:第四个词

言归正传,除了前面三个词,还有一个词在无锁代码里几乎每天都会用到,但标准把它和 happens-before 放得同样正式:修改顺序(modification order)。

All modifications to a particular atomic object M occur in some particular total order, called the modification order of M.

—— C++ draft [intro.races]

它管的是同一个原子变量上的写入顺序:所有线程对这个变量的写,构成一个所有线程都同意的全序。注意它只保证"次序一致",不保证"谁看到了谁"——一个 relaxed 的读看到的是修改顺序里的某个位置,但它可能看到的是较早的那个写。这正是 relaxed 缺的那一环:修改顺序给你"先后",synchronizes-with 才给你"可见性"。

四个词的分工摆在一起就清楚了:

词 管什么 谁提供
sequenced-before 同一线程内的顺序 语言本身
synchronizes-with 跨线程的"我之前的都对你可见" release / acquire 配对
happens-before 前两者的传递闭包 语言本身(由前两者推出)
modification order 同一个原子变量上的写入全序 原子变量本身

实践含义:一个原子变量即使全是 relaxed 操作,它的修改顺序仍然存在——所以"永远不会读到一个中间态"这类性质由它保证;但"读到了新值就一定读到了配套的普通变量"这类性质,只有 happens-before 能保证。

四个词的分工表:sequenced-before 管线程内、synchronizes-with 管可见性、happens-before 是传递闭包、modification order 管同一变量上的写入全序
图 10-34 四个词的分工表:sequenced-before 管线程内、synchronizes-with 管可见性、happens-before 是传递闭包、modification order 管同一变量上的写入全序@ 本系列绘制

1.5 官方口径对得上

说白了,把三段原文摆在一起,"x86 免费"这件事的官方依据就成形了:

工具链 / 来源 官方表述 出处
cppreference On strongly-ordered systems — x86, SPARC TSO, IBM mainframe, etc. — release-acquire ordering is automatic for the majority of operations. No additional CPU instructions are issued memory_order
Intel SDM §9.2.3.4 Loads May Be Reordered with Earlier Stores to Different Locations SDM Vol 3A
Arm STLR prevents earlier memory accesses from reordering after it, while the LDAR prevents later memory accesses from reordering before it Arm Learning Path

三家措辞的共同点与分歧点:cppreference 那句是"大多数操作免费"(majority,不是全部——§四的实测就是去找出那个例外);Intel 的小节标题是厂商自己承认允许一种重排;Arm 的表述说明同样的语义在弱序机器上必须生成指令。

⇒ "免费"是机器给的,不是语言给的。 这句话是本篇的第二个题眼。

把这三段并排放,还能看出一个共同的处理方式:三方都在描述保证的边界,而不是给出"快不快"的结论。cppreference 说"不产生额外指令"说的是指令生成,Intel 说的是允许什么重排,Arm 说的是哪些访问不能越过哪些访问——三者都不等于"这个操作花多少纳秒"。要得到后者,只能自己量。

官方口径三连:cppreference、Intel SDM 与 Arm 三家对保证边界的表述并置,没有一家给出时间数字
图 10-45 官方口径三连:cppreference、Intel SDM 与 Arm 三家对保证边界的表述并置,没有一家给出时间数字@ cppreference / Intel SDM / Arm 官方文档

二、x86 到底承诺了什么

2.1 SDM 的七条:厂商自己列的"允许与不允许"

OK,Intel SDM 卷 3 第 9 章把内存序规则逐条列了出来。这份清单比任何二手解读都清楚,因为它是厂商对"我的 CPU 会做什么"的公开声明:

SDM 小节 标题(逐字) 含义
§9.2.3.2 Neither Loads Nor Stores Are Reordered with Like Operations 同类操作之间不重排(LoadLoad、StoreStore 有序)
§9.2.3.3 Stores Are Not Reordered With Earlier Loads Store 不越过更早的 Load(LoadStore 有序)
§9.2.3.4 Loads May Be Reordered with Earlier Stores to Different Locations Load 可以越过更早的、不同地址的 Store(StoreLoad 可重排)
§9.2.3.5 Intra-Processor Forwarding is Allowed 允许处理器内部转发(读到自己缓冲里的写)
§9.2.3.7 Stores Are Seen in a Consistent Order by Other Processors 其他处理器看到的 store 顺序一致
§9.2.3.8 Locked Instructions Have a Total Order 带锁指令有全序
§9.2.3.9 Loads and Stores Are Not Reordered with Locked Instructions Load/Store 不越过带锁指令

这七条里,§9.2.3.4 是一切的根源。 前三条都在说"x86 很强"(同类有序、LoadStore 有序),第四条则给出了例外:唯一被放松的是 StoreLoad。而顺序一致(SC)恰恰不允许这种重排。

⇒ "x86 是强内存模型"这句话对了一半:它对 LoadLoad、LoadStore、StoreStore 都是强序,唯独在 StoreLoad 上是弱序。

2.2 为什么允许:store buffer

好了,允许 StoreLoad 重排的物理原因是写缓冲(store buffer)。x86-TSO 模型把这件事写成了三条规则:

The store buffers are FIFO and a reading thread must read its most recent buffered write, if there is one, to that address; otherwise reads are satisfied from shared memory. An MFENCE instruction flushes the store buffer of that thread. To execute a LOCK'd instruction, a thread must first obtain the global lock. At the end of the instruction, it flushes its store buffer and relinquishes the [lock].

—— Sewell 等:x86-TSO(CACM)

这三条规则解释了三件事:

  1. 为什么 StoreLoad 可以重排:store 写完先进缓冲,还没到共享内存;后面的 load 从共享内存读,可能读到旧值。于是从"其他线程看到的顺序"看,像是 load 越过了 store。
  2. 为什么 mfence 有效:它把本线程的 store buffer 刷干净。
  3. 为什么带锁指令有全序:它要取一个全局锁,取之前刷缓冲、放锁时也刷——这正好解释了 §6 那个反直觉读数:既然带锁指令本身就有全序,seq_cst fence 用一条带锁指令实现就够了,不必用 mfence。

而 StoreLoad 重排有一个经典的可观测后果,论文里的表述是:

given two distinct memory locations x and y (initially holding 0), if two processors respectively write 1 to x and y and then read from y and x ... it is possible for both to read 0 in the same execution.

两个线程都读到 0 —— 这在顺序一致下不可能发生(总有一方的写先可见)。§五会拿这个预测去对本机读数。

x86-TSO 的 store buffer 模型:写先进 FIFO 缓冲、读可能直接来自共享内存、mfence 刷缓冲、带锁指令取全局锁
图 10-56 x86-TSO 的 store buffer 模型:写先进 FIFO 缓冲、读可能直接来自共享内存、mfence 刷缓冲、带锁指令取全局锁@ 本系列绘制

2.3 硬件有序不等于编译器有序

言归正传,SDM 那七条规则管的是 CPU。但 C++ 程序还有第二个会重排的角色:编译器。

这一点在 relaxed 上最容易踩:relaxed 对编译器几乎等于"没有约束"——它只要求"这次访问不被消除",不要求它与相邻的普通访问保持顺序。所以:

std::atomic<bool> ready{false};
int payload = 0;

// 【1】发布者:这两行的顺序在 relaxed 下不受保护
payload = 42;                              // 普通变量写
ready.store(true, std::memory_order_relaxed);   // relaxed 发布

// 【2】订阅者:同样不成立
while (!ready.load(std::memory_order_relaxed)) { /* spin */ }
use(payload);                              // 不保证看到 42

两处都不成立的原因不同:payload = 42 可能被编译器挪到 store 之后(编译器重排);即使编译顺序正确,CPU 也可能让这两次写对别的核呈现为乱序(硬件重排,§2.2 的 store buffer)。

因此"硬件强序所以不用管内存序"这句话有两个漏:一是它只覆盖硬件那一半,编译器那一半不在范围内;二是它只覆盖 StoreLoad 之外的三类重排,而 §2.2 那 2.6% 恰好落在 StoreLoad 上。

正确的写法是把两个角色一次说清:release/acquire 的价值不在于它生成了什么指令(在 x86 上什么都不生成),而在于它同时约束了编译器和硬件——前者由语言的抽象机规则保证,后者由硬件的内存模型保证。

两个重排来源:编译器重排(relaxed 不约束)与硬件重排(store buffer),以及 release/acquire 如何同时约束两者
图 10-67 两个重排来源:编译器重排(relaxed 不约束)与硬件重排(store buffer),以及 release/acquire 如何同时约束两者@ 本系列绘制

2.4 缓存一致 ≠ 顺序一致

说白了,这里有一处最容易混淆的地方:缓存一致性(cache coherence)与内存一致性(memory consistency)是两个不同层次的承诺。

管什么 x86 给不给
缓存一致性 同一个地址的读写,所有核看到同一个顺序(per-location) 给(这是 MESI 一类协议负责的)
内存一致性 不同地址的读写,跨核看到的顺序关系(across locations) 不完全给(§9.2.3.4 就是例外)

MESI 保证了"每个缓存行内部"的一致,但从不承诺"不同缓存行之间"的顺序。 这正是为什么一台拥有一致缓存的机器仍然可以有 StoreLoad 重排——两条规则管的是两件事。

缓存一致性与内存一致性的分工:per-location 的顺序由缓存协议给,across-locations 的顺序才是内存模型管的
图 10-78 缓存一致性与内存一致性的分工:per-location 的顺序由缓存协议给,across-locations 的顺序才是内存模型管的@ 本系列绘制

把这两条规则的物理载体放上来看一眼:下面这张是 Commons 上的多核处理器照片(CC BY-SA 2.0)——一颗封装里就有多个核心,每个核心带自己的私有缓存,一致性协议要维持的那份“同一地址的单一视图”,就发生在这些私有缓存之间:

多核处理器与它的插槽:一颗封装里多个核心各带私有缓存,一致性协议维持的正是这些私有缓存之间同一地址的单一视图
图 10-89 多核处理器与它的插槽:一颗封装里多个核心各带私有缓存,一致性协议维持的正是这些私有缓存之间同一地址的单一视图@ Wikimedia Commons(CC BY-SA 2.0,Bill Abbott)

看这张照片要想的是它照不到什么:MESI 这类协议描述的只是一个缓存行在多个核之间的状态迁移,全程不涉及“不同地址之间的顺序”。这正是 §2.3 那张分工表想说的——把这类协议的规则讲完,仍然得不出“不会发生 StoreLoad 重排”的结论。

三、全档位反汇编:谁带锁、谁用 xchg、谁什么都不加

机制说完,开始看指令。这是本篇证据最硬的一章——内存序最终要落到指令上,而指令是可以逐条数的。

3.1 被测对象:每个"档位"都是一个独立函数

OK,把每个组合写成独立函数还有一个不显眼的好处:它让反汇编的输出可以直接对照。如果一个函数里塞了多个档位,编译器可能把共同的部分提取出来,或者因为上下文而改变某一档的生成结果,读数就不再是这个档位本身的代价,而那恰恰是本节的测量对象。

要看清内存序对应什么指令,最干净的做法是把每个组合写成一个独立函数,让编译器只编译这一个组合:

// 【1】load 的三档(store 只有两档,RMW 五档齐全)
int ld_relaxed(std::atomic<int>* p) { return p->load(std::memory_order_relaxed); }
int ld_acquire(std::atomic<int>* p) { return p->load(std::memory_order_acquire); }
int ld_seq_cst(std::atomic<int>* p) { return p->load(std::memory_order_seq_cst); }

// 【2】store 的三档
void st_relaxed(std::atomic<int>* p, int v) { p->store(v, std::memory_order_relaxed); }
void st_release(std::atomic<int>* p, int v) { p->store(v, std::memory_order_release); }
void st_seq_cst(std::atomic<int>* p, int v) { p->store(v, std::memory_order_seq_cst); }

// 【3】RMW:fetch_add 与 CAS,各自五档
int fa_relaxed(std::atomic<int>* p) { return p->fetch_add(1, std::memory_order_relaxed); }
bool cas_acq_rel(std::atomic<int>* p, int& e, int d) {
  return p->compare_exchange_strong(e, d, std::memory_order_acq_rel);
}

下面对这段代码特别说明三点。

第一,这些函数必须 noinline(或干脆各自独立编译)。一旦被内联进调用者,上下文会改变编译器的选择,读数就不再是"这个档位本身"的代价。

第二,load 只有三档、store 只有三档。这不是笔误:std::atomic::load 接受 relaxed / acquire / seq_cst,传入 release 是未定义行为;store 同理。语言层面就已经规定了哪些组合合法。

第三,RMW 是五档齐全的(relaxed / acquire / release / acq_rel / seq_cst),因为一次 RMW 既包含读也包含写。

被测用例的写法:每个「操作 × 内存序」组合写成独立函数,load 与 store 各三档、RMW 五档
图 10-910 被测用例的写法:每个「操作 × 内存序」组合写成独立函数,load 与 store 各三档、RMW 五档@ 本系列绘制

3.2 全档位反汇编对照表

好了,两套工具链的实测结果如下(lock 列表示是否出现 lock 前缀;xchg 列表示是否使用了 xchg):

操作 内存序 MSVC GCC lock xchg mfence 条数 字节
load relaxed / acquire / seq_cst mov eax,[rcx] mov eax,[rcx] 否 否 否 1 2
store relaxed / release mov [rcx],edx mov [rcx],edx 否 否 否 1 2
store seq_cst xchg edx,[rcx] xchg [rcx],edx 隐含 是 否 1 2
fetch_add 全 5 档 lock xadd + mov mov + lock xadd 是 否 否 2 6
CAS(strong / weak) 全 5 档 mov + lock cmpxchg + sete 同左 是 否 否 3 10

这张表里有四个结论,每一个都能单独拿出来讲。

结论一:load 的三个档位生成完全相同的指令。 一行 mov。在 x86 上把 load(acquire) 换成 load(relaxed),指令层面没有任何变化——省不下东西,也不会变慢。

结论二:store 的 relaxed 与 release 都是 mov,只有 seq_cst 变成 xchg。 这是本篇的核心发现:seq_cst 的免费与否,取决于它出现在 load 还是 store 上。

结论三:RMW 的五个档位生成完全相同的指令。 fetch_add 不论 relaxed 还是 seq_cst,都是 lock xadd;CAS 不论哪一档,都是 lock cmpxchg。因为 lock 前缀本身就已经提供了全序(SDM §9.2.3.8:Locked Instructions Have a Total Order),所以再往上加内存序不需要额外指令。

结论四:整张表里没有一条 mfence。 这不是遗漏——两套工具链的全部原子操作(共 22 个组合)都没有生成 mfence。屏障效果全部由 lock 前缀与 xchg 的隐含锁提供。

全档位反汇编对照表:22 个「操作 × 内存序」组合的指令序列,标出 lock 前缀、xchg 与指令条数/字节数
图 10-1011 全档位反汇编对照表:22 个「操作 × 内存序」组合的指令序列,标出 lock 前缀、xchg 与指令条数/字节数@ 本机实测(MSVC 19.44 + GCC 16.2)

3.3 load:三档一行

言归正传,单独把 load 的三档放出来,因为它是最容易被过度设计的地方:

; MSVC 19.44 /O2 —— 三档完全相同
ld_relaxed  PROC    mov eax, DWORD PTR [rcx]    ret
ld_acquire  PROC    mov eax, DWORD PTR [rcx]    ret
ld_seq_cst  PROC    mov eax, DWORD PTR [rcx]    ret

; GCC 16.2 -O2 —— 同样三档完全相同
ld_relaxed: mov eax, DWORD PTR [rcx]    ret
ld_acquire: mov eax, DWORD PTR [rcx]    ret
ld_seq_cst: mov eax, DWORD PTR [rcx]    ret

为什么这样是对的? 因为 x86 不允许 LoadLoad 重排(SDM §9.2.3.2),也不允许后面的 load 越过更早的 load。所以 acquire 语义所要求的"我之后的读写不能提到我前面",x86 硬件本来就保证。编译器只需要防止自己重排,不需要插指令。

seq_cst 的 load 也没什么可加——全序的约束主要体现在 store 侧:所有 seq_cst 操作要在同一个全序里,而 load 只要"看到最后那个写"就自然满足。

load 三档的反汇编并列:MSVC 与 GCC 上都是同一行 mov,三档逐字节相同
图 10-1112 load 三档的反汇编并列:MSVC 与 GCC 上都是同一行 mov,三档逐字节相同@ 本机实测(MSVC 19.44 + GCC 16.2)

3.4 store:mov 与 xchg 的分野

说白了,这是本篇唯一一处有实质差异的地方:

; MSVC —— relaxed / release 是 mov,seq_cst 变成 xchg
st_relaxed:  mov DWORD PTR [rcx], edx      ; 2 字节
st_release:  mov DWORD PTR [rcx], edx      ; 2 字节
st_seq_cst:  xchg edx, DWORD PTR [rcx]     ; 2 字节(隐含 lock)

; GCC —— 同样
st_relaxed:  mov DWORD PTR [rcx], edx
st_release:  mov DWORD PTR [rcx], edx
st_seq_cst:  xchg DWORD PTR [rcx], edx

为什么 seq_cst 的 store 非要一条带锁的指令? 回到全序的定义:所有 seq_cst 操作必须在一个全局顺序里。对 store 来说,"我这次写在全局顺序里的位置"必须马上确定下来——而这要求这次写立刻对其他核可见。普通的 mov 做不到这一点:它把值放进 store buffer,什么时候到共享内存由硬件决定。所以编译器换成了 xchg——xchg 隐含 lock,而带锁指令会先取全局锁、结束时刷自己的 store buffer(§2.2 的第三条规则)。

一处工程上的细节:xchg edx, [rcx] 会写回 edx(交换是把旧值取回来)。这也让这次 store 多了一个"读回旧值"的动作——虽然本机读数(+1.8 ns)显示代价主要来自锁而不是这次读回。

⚠️ 还有一处必须点明:MSVC 的 store(v) 默认就编译成 xchg。因为 std::atomic::store 的默认参数是 seq_cst。这一点与 ep09 的"默认 potentially-throwing"同构——语言的默认值是安全的那一个,也就是贵的那一个。

store 三档的反汇编并列:relaxed 与 release 是 mov,seq_cst 换成 xchg(隐含 lock),两套工具链一致
图 10-1213 store 三档的反汇编并列:relaxed 与 release 是 mov,seq_cst 换成 xchg(隐含 lock),两套工具链一致@ 本机实测(MSVC 19.44 + GCC 16.2)

3.5 RMW:五档一行,因为 lock 已经给了全序

; MSVC —— fetch_add 五档完全相同
fetch_add:   lock xadd DWORD PTR [rcx], edx     ; 隐含 lock,6 字节
             mov eax, edx

; MSVC —— CAS 五档完全相同
cas:         mov eax, edx
             lock cmpxchg DWORD PTR [rcx], r8d
             sete al

RMW 的五个档位生成同一段指令,理由是 SDM §9.2.3.8 那条:带锁指令本身有全序。既然 lock xadd 已经把这次操作放进了某个全局顺序,那么再声明 seq_cst 也不会让它更"全序"——编译器自然什么都不用加。

这条推论在工程上很有用:如果你的原子变量只做 fetch_add 或 CAS,那么把它从 seq_cst 改成 relaxed 不会省下任何指令,也不会变快。 §四的读数会证实这一点。

顺带把指令与字节数汇总一下:

操作 指令数 机器码字节 相对 load 的倍数(字节)
load(三档) 1 2 1.0×
store relaxed / release 1 2 1.0×
store seq_cst 1 2 1.0×
fetch_add(五档) 2 6 3.0×
CAS(五档) 3 10 5.0×

注意这一列"字节数":seq_cst 的 store 与 relaxed 的 store 字节数相同(都是 2)。 差异不在长度上,在这条指令做了什么上——xchg 与 mov 的编码长度一样,微架构行为完全不同。这也是"看代码大小判断开销"会失效的一个例子。

fetch_add 五档的反汇编:都是 lock xadd;并给出指令数与字节数汇总,点出 seq_cst store 与 relaxed 字节数相同
图 10-1314 fetch_add 五档的反汇编:都是 lock xadd;并给出指令数与字节数汇总,点出 seq_cst store 与 relaxed 字节数相同@ 本机实测(MSVC 19.44 + GCC 16.2)

3.6 同一份代码在 ARM 上长什么样(无本机实测)

OK,前一节那张全档位表是 x86 的答案。把同一批函数换到 ARM 上,结果完全不同——这一节只引官方表述,本机无 ARM(§0.3 已声明)。

语义需求 x86-64 实测 Arm 的官方表述
release store mov STLR —— "prevents earlier memory accesses from reordering after it"
acquire load mov LDAR —— "prevents later memory accesses from reordering before it"
全序 fence 1 条带锁指令 DMB —— "DMB instructions are Sequentially Consistent with respect to other DMB instructions"

STLR prevents earlier memory accesses from reordering after it, while the LDAR prevents later memory accesses from reordering before it. This is why these are also called one-way barriers ... LDAR and STLR instructions are Sequentially Consistent with respect to other LDAR and STLR instructions, while ordinary LDR and STR are not.

—— Arm Learning Path:内存一致性

这张对照表解释了"免费"这个词的归属:x86 给的是硬件级的免费——它不允许大多数重排,所以编译器不必插指令;Arm 给的是指令级的支持——用 ldar/stlr 这种单向屏障把需要的顺序补上。同一份 C++ 代码,在 x86 上编译成 mov、在 Arm 上编译成 ldar/stlr,而两边都符合标准。

实践含义有两条:一是在 x86 上调好的无锁代码,换到 Arm 上"变慢"是正常的(多出了屏障指令),不要以为编译器变笨了;二是在 x86 上"看起来能用"的代码,换到 Arm 上可能直接错——因为 x86 免费给的那部分保证,Arm 要靠指令来给,而如果你的代码里根本没有 release/acquire 的标注,编译器就不会生成那些指令。

同一批函数在 x86 与 Arm 上的对照:x86 是 mov、Arm 必须 ldar/stlr;「免费」是硬件给的、语言层面并不免费
图 10-1415 同一批函数在 x86 与 Arm 上的对照:x86 是 mov、Arm 必须 ldar/stlr;「免费」是硬件给的、语言层面并不免费@ 本系列绘制

四、时间侧实测

:只有 store 付钱

4.1 单线程读数

好了,单线程、无竞争、5,000 万次,单位 ns/op:

操作 内存序 MSVC GCC
load relaxed 0.4970 0.3692
load seq_cst 0.2699 0.3967
store relaxed 0.4030 0.4923
store seq_cst 2.1699 2.1970
fetch_add relaxed 2.0392 2.0839
fetch_add seq_cst 2.2205 2.0623
CAS(strong) relaxed 2.5982 2.4371
CAS(strong) seq_cst 2.3134 2.4949

读数与反汇编完全对得上:

  • load:两档指令相同,读数差(MSVC 那 0.27 ns 的"seq_cst 更快")是抖动,不可解读。
  • store:relaxed 是 0.40 / 0.49,seq_cst 是 2.17 / 2.20,稳定的 5 倍左右。这一档的差异在两套工具链上一致,而且与前一场的编译结果一致,可以当结论。
  • fetch_add 与 CAS:两档各自相差 0.1 至 0.3 ns,且方向不一致(MSVC 上是 relaxed 更快,GCC 上是 seq_cst 更快)。指令既然逐字节相同,这点差异就是抖动。

一条报告纪律:凡是"指令逐字节相同而读数有小差"的情况,一律按抖动处理,不解读、不作为结论。本篇有四处属于这一类(load 两档、fetch_add 两档、CAS 两档、以及 §八的 weak/strong)。

单线程各档读数面板:load 两档并列、store 两档并列、fetch_add 与 CAS 各两档,标注哪几档指令相同、哪些差异是抖动
图 10-1516 单线程各档读数面板:load 两档并列、store 两档并列、fetch_add 与 CAS 各两档,标注哪几档指令相同、哪些差异是抖动@ 本机实测(MSVC 19.44 + GCC 16.2)

4.2 把三类操作分开看

言归正传,把 §4.1 的读数按操作类型重排,"免费"这件事就清楚了:

操作类型 relaxed seq_cst 差值 指令是否相同
load(MSVC / GCC) 0.4970 / 0.3692 0.2699 / 0.3967 抖动 是
store(MSVC / GCC) 0.4030 / 0.4923 2.1699 / 2.1970 +1.77 / +1.70 否(mov → xchg)
fetch_add(MSVC / GCC) 2.0392 / 2.0839 2.2205 / 2.0623 抖动 是
CAS(MSVC / GCC) 2.5982 / 2.4371 2.3134 / 2.4949 抖动 是

四类里只有 store 有稳定代价。 这就是 §0.1 那句结论的完整依据。

再看一个反面的对照:load 与 store 的 relaxed 档都只要 0.4 ns 左右,而 fetch_add 无论哪一档都要 2.0 ns 以上。RMW 的固有成本(lock 前缀)已经盖过了内存序的差异——一个 fetch_add(relaxed) 比一个 store(seq_cst) 还要贵一点点。这条对照说明:优化原子操作时,先问"能不能不用 RMW",再问"用哪个内存序"。

只有 store 有代价:三类操作按 relaxed/seq_cst 并排,标出 store 的 +1.77/+1.70 ns,其余三类的差值落在抖动内
图 10-1617 只有 store 有代价:三类操作按 relaxed/seq_cst 并排,标出 store 的 +1.77/+1.70 ns,其余三类的差值落在抖动内@ 本机实测(MSVC 19.44 + GCC 16.2)

4.3 lock 前缀到底值多少钱

说白了,既然 lock 是成本的主因,单独量一次它:

操作 MSVC GCC 说明
普通 add(非原子) 0.2589 0.2259 基线
volatile store 0.2563 0.3317 无屏障
atomic store relaxed(mov) 0.2645 0.3067 与 volatile 同级
atomic store seq_cst(xchg) 2.0629 2.1538 隐含锁
atomic fetch_add relaxed(lock xadd) 2.0279 2.2378 显式锁
atomic fetch_add seq_cst 2.0770 2.0914 同左

结论很干净:

  • 从普通 add 到 lock add,代价是 +1.77 ns(MSVC)/ +2.01 ns(GCC),约 7 至 9 倍。这是"原子性"本身的价钱。
  • lock xadd 的 relaxed 与 seq_cst 完全同价(2.0279 对 2.0770;2.2378 对 2.0914)——再次印证 §3.5。
  • xchg 的代价落在同一档(2.0629 / 2.1538),与 lock xadd 基本同价。两个不同的指令,代价却一样,因为它们都带锁。

⇒ 这张表把"内存序的代价"与"原子性的代价"分开了:前者只在 seq_cst 的 store 上出现;后者是 RMW 的入场费,与内存序无关。

lock 前缀的成本阶梯:普通 add / volatile / relaxed store / seq_cst store / fetch_add 两档,标出原子性入场费与 seq_cst 的额外代价
图 10-1718 lock 前缀的成本阶梯:普通 add / volatile / relaxed store / seq_cst store / fetch_add 两档,标出原子性入场费与 seq_cst 的额外代价@ 本机实测(MSVC 19.44 + GCC 16.2)

4.4 因果链:为什么偏偏是 store

OK,把这一章串起来,seq_cst store 那条 +1.8 ns 的因果链是完整的:

  1. seq_cst 要求所有 seq_cst 操作在一个单一全序里(§1.3 的标准原句);
  2. 对 load 来说,满足这个要求不需要额外动作(x86 不允许 LoadLoad 重排,load 自动看到最新值);
  3. 对 store 来说,要"立刻确定自己在全序里的位置",就必须让这次写马上对其他核可见;
  4. 普通 mov 做不到——它先把值放进 store buffer(§2.2 的第一条规则);
  5. 于是编译器换成 xchg(隐含 lock),带锁指令会取全局锁并刷新自己的 store buffer(§2.2 的第三条规则);
  6. 这条锁就是那 1.8 ns。
seq_cst store 变贵的因果链六步:全序要求 → load 无需额外动作 → store 需立刻可见 → mov 做不到 → 换成 xchg → 带锁刷缓冲
图 10-1819 seq_cst store 变贵的因果链六步:全序要求 → load 无需额外动作 → store 需立刻可见 → mov 做不到 → 换成 xchg → 带锁刷缓冲@ 本系列绘制

4.5 release / acquire 在 x86 上同样是免费的

好了,这一节的结论对写代码的人有直接影响:在 x86 上按语义需求写 release 与 acquire 是零成本的,所以没有理由为了省一点而把它们降成 relaxed。降级只会丢掉 happens-before 这份保证,不会省下任何指令,也不会让这段代码变快哪怕一点。

§4.2 那张表按"操作类型"分组,但还有一处值得单独确认:同一种操作里,release 与 acquire 与 relaxed 是否同价? 实测的答案是与反汇编一致的:

操作 relaxed release / acquire seq_cst
store(MSVC / GCC) 0.4030 / 0.4923 同 relaxed(同为 mov) 2.1699 / 2.1970
load(MSVC / GCC) 0.4970 / 0.3692 同 relaxed(同为 mov) 0.2699 / 0.3967

release / acquire 在 x86 上与 relaxed 同价——因为三者的指令完全相同(§3.3 / §3.4)。这也与 cppreference 那句"release-acquire ordering is automatic for the majority of operations. No additional CPU instructions are issued"完全对上。

⇒ 这是本篇最实用的一条结论:在 x86 上,按语义需要写 release / acquire 是完全免费的。既然免费,就没有理由为了"省一点"而降成 relaxed——降级只会丢掉 happens-before,不会省下任何东西。

这条与 §4.2 的结论合起来,构成一个清晰的取舍:

你想要的 在 x86 上要付的钱
原子性(不被撕裂) RMW 上的 lock 入场费(约 1.8 ns)
可见性(happens-before) 0(写 release / 读 acquire 都是 mov)
全序(所有线程同意先后) 只在 store 上付(约 1.8 ns)
release 与 acquire 在 x86 上与 relaxed 同价:store 与 load 三档并列,标出只有 seq_cst 的 store 有代价
图 10-1920 release 与 acquire 在 x86 上与 relaxed 同价:store 与 load 三档并列,标出只有 seq_cst 的 store 有代价@ 本机实测(MSVC 19.44 + GCC 16.2)

4.6 判据(第三条之外的第四条)

判据一(时间侧):先问操作类型,再问内存序:

  • 只做 load → 三档随便挑,指令与时间都一样;
  • 只做 store → relaxed / release 免费,seq_cst 要付约 1.8 ns(5 至 8 倍);
  • 做 RMW(fetch_add / CAS)→ 五个档位同价,内存序不影响时间,按语义需求挑即可。

判据二(默认值):std::atomic 的默认是 seq_cst。如果这个原子变量在热路径上且只做 store,把默认值改成 release 是一处真实的收益(约 5 倍);但改之前要确认这次 store 之后的可见性需求(§五会讲哪些场景必须付这笔钱)。

五、StoreLoad 重排的实证:千万轮里的 2.6% 与 0%

言归正传,§2.1 说 SDM 承认 StoreLoad 可以重排,§2.2 说原因是 store buffer。这一章去量它到底发不发生、多频繁。

5.1 实验设计:两个线程,两个变量,各写各读

说白了,这个测试在文献里叫 store-buffering(SB)litmus 测试。构造极简:

// 【1】两个原子变量,初值都是 0
std::atomic<int> X{0}, Y{0};

// 【2】线程 A:先写 X,再读 Y
void writer_a(int *r1) {
  X.store(1, ORDER);           // 写自己的
  *r1 = Y.load(ORDER);         // 读对方的
}

// 【3】线程 B:先写 Y,再读 X(与 A 完全对称)
void writer_b(int *r2) {
  Y.store(1, ORDER);
  *r2 = X.load(ORDER);
}

// 【4】主控:反复跑这对线程,统计"两边都读到 0"的次数
for (long round = 0; round < 10'000'000; ++round) {
  X.store(0, std::memory_order_relaxed);
  Y.store(0, std::memory_order_relaxed);
  int r1 = -1, r2 = -1;
  std::thread ta(writer_a, &r1), tb(writer_b, &r2);
  ta.join(); tb.join();
  if (r1 == 0 && r2 == 0) ++hit;    // 顺序一致下这不该发生
}

下面对这段代码特别说明三点。

第一,ORDER 是被测变量。整组实验跑两遍:一遍填 relaxed,一遍填 seq_cst,其余代码完全相同。

第二,判定条件是 r1 == 0 && r2 == 0。按顺序一致(SC),两个线程的四个操作存在某个全局顺序,无论怎么排,至少有一方会读到对方的写(1)。所以"两边都读到 0"在 SC 下不可能发生。x86-TSO 的预测是它可能发生(§2.2 的 SB 引文)。

第三,每轮都重建线程。这样做的代价是启动开销大(每轮约 3 微秒),但保证两个线程真的在不同的时间片上跑;如果复用线程 + 循环内同步,测量本身就会引入屏障。

⚠️ 一处必须标注的边界:这个实验只能证明"允许发生"和"实际发生了多少次",不能证明"一定发生"。它是统计式实证,不是形式化检验(本机无 herd7,§0.3 已声明)。

store-buffering 实验设计:两个线程各写自己的变量、各读对方的变量,判定两边是否都读到 0
图 10-2021 store-buffering 实验设计:两个线程各写自己的变量、各读对方的变量,判定两边是否都读到 0@ 本系列绘制

5.2 读数:relaxed 稳定命中,seq_cst 一次不中

OK,1,000 万轮,两套工具链各跑一遍:

内存序 MSVC 命中 r1==0 && r2==0 GCC 命中
relaxed 261,643(2.616430%) 306,941(3.069410%)
seq_cst 0(0.000000%) 0(0.000000%)

两套工具链的结论一致,而且极其干净:

  • relaxed 下稳定复现,命中率 2.6% 至 3.1%——这个量级说明它不是偶发噪声,而是 StoreLoad 重排的常态表现;
  • seq_cst 下 1,000 万轮一次不中——xchg 与 lock 把 store buffer 刷干净了(§3.4)。

这条读数把"可能"变成了一个频率。 中文侧的同类文章大多停在"StoreLoad 重排是可能的",读了之后读者无法判断该不该为它花钱。2.6% 与 3.1% 这两个数字才是可决策的依据:如果你的程序在 relaxed 下靠 X、Y 两个标志互相等待,大约每 35 次就会有一次两边都没看到对方。

StoreLoad 重排命中率面板:relaxed 的 2.616430% 与 3.069410%,seq_cst 的 0 与 0,两套工具链并列
图 10-2122 StoreLoad 重排命中率面板:relaxed 的 2.616430% 与 3.069410%,seq_cst 的 0 与 0,两套工具链并列@ 本机实测(MSVC 19.44 + GCC 16.2)

5.3 重排的物理载体

好了,重排这个概念容易停在抽象的可能性上,但它在硬件里对应一个具体的结构:写先进缓冲,读从缓冲或共享内存取值。理解了这个结构,前两节那些规则就不再是一份清单,而是一台机器正在做的事;也才能解释为什么某些屏障指令是必需的,另一些则是多余的。

重排不是一个抽象概念,它需要硬件里的一个具体结构来支撑。x86 的这个结构就是 load/store 队列——写先进队列(store buffer),读可以从队列转发、也可以直接去共享内存。

下面这张是 Commons 上的 load/store 队列图,公共域:

load/store 队列:写进入缓冲队列、读从队列或共享内存取值
图 10-2223 load/store 队列:写进入缓冲队列、读从队列或共享内存取值@ Wikimedia Commons(CC0,公共域等价)

这张图给出了 §2.2 那三条规则的物理落点:store buffer is FIFO 对应队列的先进先出;"reading thread must read its most recent buffered write" 对应队列转发;"MFENCE flushes the store buffer" 对应把队列排空。

⚠️ 一处尺寸说明:这张图只有 711×392,属原理小图,正文里按小图排版,不放大到全宽。

load/store 队列作为重排的物理载体:写缓冲的 FIFO 结构、读的转发布径与共享内存取值的分工
图 10-2324 load/store 队列作为重排的物理载体:写缓冲的 FIFO 结构、读的转发布径与共享内存取值的分工@ 本系列绘制

5.4 理论预测与实测对照

言归正传,把 §2.2 的论文预测、§2.1 的 SDM 规则、与 §5.2 的实测放在一起:

来源 表述 性质
Intel SDM §9.2.3.4 Loads May Be Reordered with Earlier Stores to Different Locations 规范:允许
x86-TSO 论文 it is possible for both to read 0 in the same execution 模型:可能
本机实测(relaxed) MSVC 2.616430% | GCC 3.069410% 读数:发生了,而且有固定频率
本机实测(seq_cst) 0 | 0 读数:一次不中

四行放在一起,一件事就完整了:规范允许、模型预测、实测复现、并且 seq_cst 确实能消掉它。这是本篇证据链最完整的一处——从一手的规范条文一路走到本机的命中计数。

SB litmus 的四行对照:SDM 允许、x86-TSO 预测可能、本机 relaxed 实测 2.6%/3.1%、seq_cst 实测 0
图 10-2425 SB litmus 的四行对照:SDM 允许、x86-TSO 预测可能、本机 relaxed 实测 2.6%/3.1%、seq_cst 实测 0@ 本机实测(MSVC 19.44 + GCC 16.2)

5.5 2.6% 这个数字怎么读(以及不该怎么读)

说白了,§5.2 那两个数字(2.616430% 与 3.069410%)很具体,但它们的解释空间比看上去大。先说清楚它能说明什么与不能说明什么。

能说明的:

  • StoreLoad 重排确实会发生,而且有固定量级的频率——两个工具链、两个独立实验都落在 2.6% 至 3.1% 这个窄区间里;
  • seq_cst 确实能消掉它(1,000 万轮命中 0);
  • 因此"relaxed 下两个原子变量互相等待"是一个可量化的风险,而不是理论上的可能性。

不能说明的:

  • 这个百分比不是硬件的一个常数。 它高度依赖实验结构:每轮都重建线程,于是两个线程的启动与执行有一个约 3 微秒的窗口;两者的写落在窗口的不同位置时,重排才可观测。换一个线程复用、加一轮同步、或者把循环次数改掉,百分比都会变。
  • 它不能外推到真实程序。 真实程序里的竞争窗口由业务逻辑决定,不是由线程启动开销决定。把 2.6% 当成"我的程序有 2.6% 的概率出错"是错的读法。

正确的读法是把它当成一个量级判断:

你想知道 这个数字能回答吗
StoreLoad 重排到底会不会发生 能,两套工具链都复现了
它是罕见事件还是常态 能,千万轮里数十万次,不是罕见到可以忽略
我的程序出错的概率是多少 不能,那取决于你的竞争窗口
加了 seq_cst 就一定对吗 不能,这只说明这一个模式被消掉了

为什么要专门写这一节:统计式实证最容易的误用就是"把实验里的百分比当成产品的故障率"。

本篇的立场是:给出频率以证明"这不是理论问题",同时明确拒绝把频率外推为概率。

2.6% 这个数字的能说明与不能说明:证明会发生、证明不是罕见事件,但不能外推为程序故障率
图 10-2526 2.6% 这个数字的能说明与不能说明:证明会发生、证明不是罕见事件,但不能外推为程序故障率@ 本机实测(MSVC 19.44 + GCC 16.2)

5.6 判据:什么时候必须付这笔钱

判据三(StoreLoad):按这个顺序问。

  1. 这段代码有没有"两个原子变量互相等待"的形态? 有 → 这是一处 SB 结构,relaxed 下每几十次就可能失效一次,必须用 seq_cst 或显式的释放-获取配对。
  2. 只有单个原子变量的读改写? 那么 StoreLoad 重排影响不到你,按 §4.5 的判据一挑即可。
  3. release/acquire 配对够不够? 多数"发布-订阅"场景够——release store 与 acquire load 的配对能建立 happens-before(§1.2),不需要 seq_cst 的全序。
  4. 什么时候才真的需要全序? 需要多个线程对多个原子操作的先后有一致看法时。典型是"多个标志位之间的相对顺序要被所有线程同意"这类场景。
  5. 不确定就先用 seq_cst,热点再降。 这是默认值存在的理由——先证明确实是热点,再为它付设计的复杂度。

一条反向提醒:seq_cst 的代价只出现在 store 上(§4.2)。所以"我要全序,所以哪里都得写 seq_cst"是过度设计——load 与 RMW 上写 relaxed 省不下东西,但写 seq_cst 也不多花东西。真正的取舍点在 store。


六、fence 的真相:为什么一条 mfence 都没有

6.1 三类 fence 的指令现场

OK,std::atomic_thread_fence 可以带不同内存序。本机实测的结果出乎意料:

fence 内存序 MSVC GCC 指令数
acquire (函数体只剩 ret) (只剩 ret) 0
release (只剩 ret) (只剩 ret) 0
seq_cst lock inc DWORD PTR [rsp+8] lock or QWORD PTR [rsp],0x0 1

两条结论,每一条都反直觉。

第一,acquire 与 release fence 在 x86 上是空操作。 编译器完全不生成指令——函数体里只有一条 ret。理由与 §3.3 相同:x86 不允许 LoadLoad 与 StoreStore 重排,这两类 fence 要挡的东西硬件本来就挡住了,编译器只需要约束自己的重排,不需要插指令。

第二,seq_cst fence 不是 mfence。 这一点最值得单独讲:两套工具链都用了"在栈上做一个哑元加锁操作"的办法——

; GCC 16.2 —— 对栈上的任意位置做一次 lock or,操作数是 0(不改变任何值)
lock or QWORD PTR [rsp], 0x0

; MSVC 19.44 —— 对调用者的影子空间做一次 lock inc
lock inc DWORD PTR [rsp+8]

为什么是这两条? 回到 §2.2 的第三条规则:带锁指令会先取全局锁、结束时刷自己的 store buffer。一条带锁但无副作用的指令(or 0、inc 之后没人读)就能达到"刷新 store buffer + 进入全序"的效果,而这正是 seq_cst fence 要的。既然带锁指令够用,就没必要用 mfence。

MSVC 那一条尤其反直觉:它操作的是 [rsp+8],在 Windows x64 的调用约定里这是调用者的影子空间(shadow space)——也就是它改写了调用者栈帧里的一块保留区域。功能上无害(那块区域本来就不放值),但它说明编译器选择了一个"一定可写、且不会影响程序语义"的位置。

⚠️ 一处必须标注的边界:以上是本机两套工具链的实现观察,不是标准要求。seq_cst fence 完全可以实现成 mfence——事实上在较早的编译器版本上就是这样。本篇只声明"当前这两个版本选择了带锁指令",不声明"一定如此"。

seq_cst fence 的指令现场:GCC 对栈上位置做一次 lock or 0,MSVC 对调用者影子空间做一次 lock inc
图 10-2627 seq_cst fence 的指令现场:GCC 对栈上位置做一次 lock or 0,MSVC 对调用者影子空间做一次 lock inc@ 本机实测(MSVC 19.44 + GCC 16.2)

6.2 三类 fence 的对照

好了,把三类 fence 与对应的原子操作放在一起看,规律就出来了:

想表达的语义 fence 写法 x86 上的代价 等效的原子操作写法
我之后的读写不能提到我前面 atomic_thread_fence(acquire) 0 条指令 load(acquire)
我之前的读写不能掉到我后面 atomic_thread_fence(release) 0 条指令 store(release)
全部进入同一个全序 atomic_thread_fence(seq_cst) 1 条带锁指令 store(seq_cst) 或 RMW

这张表的最右一列是关键:fence 与原子操作在 x86 上落到同一批指令。已经在用带内存序的原子操作,再额外加 fence 通常是多余的——两套写法表达同一件事,只是写法不同。

什么时候 fence 才是必需的? 当屏障的位置与原子操作的位置不重合时。典型场景是"先写一批普通变量,再一次性发布"——此时如果发布用的是已有的原子变量、而你想让屏障落在它前面某处,就需要 fence。判据是位置,不是语义强弱。

三类 fence 与等效原子操作的对照:acquire/release fence 是 0 条指令,seq_cst fence 是 1 条带锁指令
图 10-2728 三类 fence 与等效原子操作的对照:acquire/release fence 是 0 条指令,seq_cst fence 是 1 条带锁指令@ 本机实测(MSVC 19.44 + GCC 16.2)

6.3 为什么"没有 mfence"值得单独写一节

言归正传,回到 §3.1 那张全档位表:22 个组合,一条 mfence 都没有。这不是偶然,而是两套工具链共同的取舍,原因有三层:

  1. 能用更便宜的东西就不上 mfence。 mfence 是通用屏障,会序列化本线程所有先前的 load 与 store;而大多数场景只需要更窄的屏障。
  2. 带锁指令已经给了全序。 SDM §9.2.3.8 那条"Locked Instructions Have a Total Order"让 lock 前缀成了一个"顺便免费"的全序来源——xchg、lock xadd、lock cmpxchg 都在提供它。
  3. mfence 只在"既不能带锁、又需要全序"时才划算。 本机这 22 个组合里没有这种情形。

这一节的价值在于破除一个常见印象:很多讲内存序的材料把 mfence 当成 x86 上"全序的代价"的代名词。本机实测表明,当前两套工具链在原子操作层面一次都没用它。

但也别反向过读:没有 mfence 不等于没有代价。§4.4 那条 1.8 ns 的因果链说明,代价换了一个形式存在——落在了 xchg 与 lock 前缀上。"没有某条指令"与"没有开销"是两件事。

为什么 22 个组合里一条 mfence 都没有:能用更便宜的、带锁指令已有全序、mfence 只在特定场景划算
图 10-2829 为什么 22 个组合里一条 mfence 都没有:能用更便宜的、带锁指令已有全序、mfence 只在特定场景划算@ 本系列绘制

七、volatile 的边界:逐字节等于 relaxed

7.1 逐字节对照

说白了,volatile 在 C++ 里常被当成"轻量版原子"。本机实测给出了一个非常干净的对照:

; MSVC —— volatile store 与 relaxed atomic store 完全相同
vol_store:        mov DWORD PTR [rcx], edx      ; volatile
st_relaxed_impl:  mov DWORD PTR [rcx], edx      ; atomic store, relaxed

; GCC —— 同样相同
vol_store:        mov DWORD PTR [rcx], edx
st_relaxed_impl:  mov DWORD PTR [rcx], edx

两条指令逐字节相同,而且都没有屏障——在 x86 上,volatile 写与 relaxed 原子写在机器指令层面是同一件事。

成本也一致:

写法 MSVC GCC
volatile store 0.2563 0.3317
atomic store(relaxed) 0.2645 0.3067
atomic store(seq_cst) 2.0629 2.1538

三者里只有 seq_cst 那一档贵,volatile 与 relaxed 同级——这符合 §4.3 那张 lock 成本表的结论。

7.2 volatile 唯一可靠的作用:挡编译器

OK,volatile 不是完全没用。本机实测里它确实做了一件事:

函数 MSVC GCC 说明
两个 volatile store(先后写不同变量) mov; mov mov; mov 保持源码顺序
两个 relaxed 原子 store mov; mov mov; mov 本例也保持顺序
两个 seq_cst 原子 store mov eax,r8d; xchg; xchg 同左 两条 xchg 强制固定顺序

volatile 能阻止编译器把两次访问重排或消除——这是它与普通变量的最大区别,也是它真正的用途(内存映射 I/O、信号处理器里的标志)。但它不阻止 CPU 重排,也不提供原子性与跨核可见性。

⚠️ 一处诚实标注:本例里 relaxed 原子 store 也保持了源码顺序——这不代表 relaxed 有顺序保证,只代表"在这个简单例子里,编译器没有动机去重排"。要观察编译器在 relaxed 下的重排,需要构造更复杂的上下文(例如让两个 store 之间存在可能影响结果的读),那属于另一组实验。本篇不据此声明 relaxed 有顺序。

volatile 与 relaxed 原子 store 的逐字节对照:两条 mov 完全相同、都不带屏障
图 10-2930 volatile 与 relaxed 原子 store 的逐字节对照:两条 mov 完全相同、都不带屏障@ 本机实测(MSVC 19.44 + GCC 16.2)

7.3 两家的官方口径:MSVC 的 volatile 默认带屏障

好了,有一处平台差异必须写明。MSVC 的 volatile 有两套语义,由开关选择:

/volatile:iso — Selects strict volatile semantics as defined by the ISO-standard C++ language. Acquire/release semantics are not guaranteed on volatile accesses.

/volatile:ms — Selects Microsoft extended volatile semantics, which add memory ordering guarantees beyond the ISO-standard C++ language. Acquire/release semantics are guaranteed on volatile accesses. However, this option also forces the compiler to generate hardware memory barriers, which might add significant overhead on ARM and other weak memory-ordering architectures.

—— MSVC /volatile 解释开关

非 ARM 平台默认是 /volatile:ms。 这句话解释了一件很容易被误读的事:同一份用 volatile 写的代码,在 MSVC 上"看起来能用"、在 GCC 上可能直接失效——因为 MSVC 的默认语义额外加了顺序保证,而 GCC 按标准只给"不被优化掉"。

⚠️ 但本机实测里 MSVC 的 volatile store 并没有生成屏障指令(§7.1 逐字节对照)。这两件事不矛盾:/volatile:ms 承诺的是"语义上有顺序保证",而在 x86 这个强序目标上,这份保证不需要额外指令(与 §3.3 的 acquire fence 同理)。接口上的保证一样,落到机器码上因为平台而不同。

volatile 挡什么、不挡什么:挡编译器重排与消除,不挡 CPU 重排、不保证原子性与跨核可见性
图 10-3031 volatile 挡什么、不挡什么:挡编译器重排与消除,不挡 CPU 重排、不保证原子性与跨核可见性@ 本系列绘制

7.4 判据:什么时候用 volatile

判据四(volatile):只有两类场景该用。

  1. 内存映射 I/O:硬件寄存器的读写必须每次真的发生,不能被优化掉或被合并。
  2. 信号处理器里的标志:volatile sig_atomic_t 这类类型,要求访问不被优化掉。

除此之外一律用 std::atomic。 理由很直接:

  • 想让别的线程看到 → 需要跨核可见性,volatile 不给(本机实测:无屏障指令);
  • 想保证读改写不丢更新 → 需要原子性,volatile 不给(x++ 会编成三条指令);
  • 想建立 happens-before → 需要 release/acquire,volatile 不给(没有语义)。

一条最容易踩的坑:用 volatile bool 当"就绪标志"、用普通变量传数据。这在 x86 上可能看起来能用(因为硬件强序),但编译器可以重排那批普通变量的写——而那正是 volatile 挡不住的地方。"在我的机器上跑了三年没问题"不是证据。

八、竞争下的账:内存序的差 vs 缓存行争抢的差

言归正传,前面七章都是单线程读数。但原子操作在真实程序里几乎总在竞争——这一章去量竞争本身,并把它与内存序的代价放在同一个尺度上。

8.1 竞争发生在哪里

多个线程对同一个原子变量做 RMW 时,争抢的对象是这个变量所在的缓存行。下面这张是 Commons 上的 SMP(Symmetric Multiprocessor,对称多处理器)示意图,公共域,给出了这个场景的物理图景:

SMP 对称多处理器:多个核共享同一片内存与缓存层次
图 10-3132 SMP 对称多处理器:多个核共享同一片内存与缓存层次@ Wikimedia Commons(CC0,公共域等价)

这张图补上了 §2.3 那张分工表缺的一角:缓存层次的共享结构,正是"同一个地址的读写会被所有核以同一顺序看到"(缓存一致性)得以成立的物理前提;而 §五 那个 2.6% 的重排,发生在不同地址之间——这一层不在图里。

8.2 竞争下的读数

说白了,竞争读数里最容易被误读的是线程越多越慢这个印象。慢下来的原因不是线程本身,而是它们争抢同一个缓存行时产生的来回协商;把变量拆开,或者让每个线程先在自己的副本上累加,读数会回到单线程那一档附近。下一节的对照表把这件事量化。

总操作数 2,000 万,线程数从 1 到 8,单位 ns/op:

操作 线程 MSVC relaxed / seq_cst GCC relaxed / seq_cst
fetch_add 1 1.8953 / 2.0345 2.3339 / 2.1655
fetch_add 2 10.0875 / 10.6070 8.8583 / 10.6863
fetch_add 8 14.3689 / 15.2255 15.1051 / 14.7974
CAS 自旋 2 16.5658 / 18.8454 16.4882 / 17.8420
CAS 自旋 4 36.8790 / 40.3591 39.9139 / 40.1791
CAS 自旋 8 46.9967 / 47.6702 42.6955 / 45.7577

这张表最值得看的是量级变化:

  • fetch_add 从 1 线程的约 2 ns,涨到 8 线程的约 15 ns——7 倍;
  • CAS 自旋从 2 线程的约 17 ns,涨到 8 线程的约 47 ns——近 3 倍(且 CAS 自旋本身就含失败重试,起点就高)。

而内存序带来的差:fetch_add 各线程数下 relaxed 与 seq_cst 相差 0.1 至 1.8 ns,方向还不一致;CAS 自旋相差 1 至 2 ns,方向同样不一致。

⇒ 一句话结论:在竞争面前,内存序的差被缓存行争抢的差淹没了。 fetch_add 单线程时两类内存序都是 2 ns,8 线程时都变成 15 ns;而这 13 ns 的增量与内存序无关。

竞争下的读数面板:fetch_add 与 CAS 自旋在 1/2/4/8 线程下的 relaxed 与 seq_cst 并列,量级增量来自争抢
图 10-3233 竞争下的读数面板:fetch_add 与 CAS 自旋在 1/2/4/8 线程下的 relaxed 与 seq_cst 并列,量级增量来自争抢@ 本机实测(MSVC 19.44 + GCC 16.2)

8.3 两条代价曲线的对照

OK,把两条"代价"放在同一张尺度上:

来源 量级 出现在哪 能不能靠改代码去掉
缓存行争抢 约 13 ns(fetch_add,1→8 线程) 多线程争同一变量 能——减少争抢(分片、每线程计数器、批量提交)
seq_cst store 的锁 约 1.8 ns 单线程也发生 能——换成 release(若语义允许)
RMW 的入场费(lock) 约 1.8 ns 单线程也发生 能——减少 RMW 次数
内存序在 RMW 上的差 0 — 改不了,也不存在

这张表给出了优化的优先顺序:

  1. 先问"能不能不用 RMW"。fetch_add 比 store(seq_cst) 还贵(§4.2),所以把热点计数器改成"每线程本地累加、定期汇总"通常收益最大。
  2. 再问"能不能减少争抢"。8 线程争一个变量比 1 线程贵 7 倍,这是最大的一笔。
  3. 最后才问"内存序能不能降"。它只在 seq_cst 的 store 上有约 1.8 ns,且在 RMW 上完全没有空间。

这与 ep08 那条结论同族:先看"是不是在热路径上",再看"能不能换个结构",最后才谈开关。内存序是最后才轮到的那一层。

两条代价曲线对照:争抢约 13 ns、seq_cst store 约 1.8 ns、RMW 入场费约 1.8 ns、RMW 上的内存序差为 0
图 10-3334 两条代价曲线对照:争抢约 13 ns、seq_cst store 约 1.8 ns、RMW 入场费约 1.8 ns、RMW 上的内存序差为 0@ 本机实测(MSVC 19.44 + GCC 16.2)

8.4 布局:atomic<T> 有没有额外开销

好了,按 ep01 的口径,把大小与对齐也量一遍:

类型 MSVC sizeof / alignof GCC sizeof / alignof lock_free
atomic<int> 4 / 4 4 / 4 是
atomic<long long> 8 / 8 8 / 8 是
atomic<short> 2 / 2 2 / 2 是
atomic<char> 1 / 1 1 / 1 是
atomic<bool> 1 / 1 1 / 1 是
atomic<int*> / atomic<void*> 8 / 8 8 / 8 是
atomic_flag 4 / 4 1 / 1 —

结论:除 atomic_flag 外,atomic<T> 没有任何额外包装开销——sizeof 与 alignof 都等于 T 本身,两套工具链一致。这与 ep01 讲过的"空基类优化"是同一件事的两面:标准库把原子包装做成了零开销。

而 atomic_flag 是一个例外:MSVC 是 4 字节,GCC 是 1 字节。 这是本机实测里唯一一处 C++ 标准库类型的 ABI 差异。实践含义:atomic_flag 不要跨编译器边界的二进制接口使用;同一个结构体里混放 atomic_flag 与其它字段时,两套工具链编出的布局会不同。

atomic<T> 的布局面板:八种类型的大小与对齐两套工具链一致,只有 atomic_flag 存在 4 字节与 1 字节的 ABI 差异
图 10-3435 atomic<T> 的布局面板:八种类型的大小与对齐两套工具链一致,只有 atomic_flag 存在 4 字节与 1 字节的 ABI 差异@ 本机实测(MSVC 19.44 + GCC 16.2)

8.5 atomic_flag 与 is_lock_free

§8.4 那张布局表里,atomic_flag 是唯一的例外(MSVC 4 字节 / GCC 1 字节)。值得单独看一下它与 is_lock_free 的关系。

标准给了 is_lock_free() 成员,用来查询"这个原子类型在这个平台上是不是真原子的"(不借助锁)。本机实测:

类型 MSVC GCC
atomic<int> / <long long> / <short> / <char> / <bool> true true
atomic<int*> / <void*> true true
atomic_flag (无此成员) (无此成员)

注意最后一行的括号:atomic_flag 没有 is_lock_free() 成员——因为标准要求它必须是无锁的,无需查询。这也解释了它的语义定位:它是最基础的原子类型,只支持两个操作(test_and_set 与 clear),但保证无锁。

两个实践含义:

  1. 需要"确定无锁"时用 atomic_flag。atomic<T> 的 is_lock_free() 理论上可能返回 false(某些平台上大类型的原子操作要靠内部锁实现),而 atomic_flag 不可能。
  2. 但别指望它省空间:MSVC 上它是 4 字节、GCC 上 1 字节。要放进二进制接口的结构体里时,这个差异会造成布局不一致——这是 ABI 层面的问题,不是性能问题。

顺带一条与 ep01 的连接:atomic<T> 在没有额外开销这件事上,与本系列 ep01 讲过的"空基类优化"是同一类做法的两面——标准库把包装做到零开销,代价是全靠编译器的类型系统而不是运行期结构。

atomic_flag 与 is_lock_free 的关系:atomic_flag 无此成员因为它必须无锁;并给出 4 字节与 1 字节的 ABI 差异
图 10-3536 atomic_flag 与 is_lock_free 的关系:atomic_flag 无此成员因为它必须无锁;并给出 4 字节与 1 字节的 ABI 差异@ 本机实测(MSVC 19.44 + GCC 16.2)

九、选型判据与工程建议

言归正传,判据的顺序本身就是结论的一部分:只有前一条不构成瓶颈,后面的权衡才有意义。把它们倒过来问,先去挑内存序,再回头看这个变量是否真的被多线程访问,通常会得出一组互相矛盾的答案,也会让本来免费的那部分被当成瓶颈去优化。

前面八章都在量代价,这一章把它们收成一组可以直接在代码评审里用的判据。

判据按顺序问,不按重要性排——因为前一条的答案会决定后一条要不要问。

选型判据决策表:按顺序问的六条,每条给出通过路径与回落路径
图 10-3637 选型判据决策表:按顺序问的六条,每条给出通过路径与回落路径@ 本系列绘制

9.1 六条判据,按顺序问

判据一(要不要原子):这个变量真的会被多个线程同时访问吗?

  • 不会 → 别用 atomic。普通变量更快,而且不需要任何内存序推理;
  • 会,但只在锁保护下 → 也用普通变量。锁已经提供了同步,锁内的原子操作是多余的一层。

判据二(要不要 RMW):能不能不用 fetch_add / CAS?

  • 能(比如每线程本地计数器 + 定期汇总)→ 这是收益最大的一步(§8.3);
  • 不能 → 接受约 2 ns 的入场费,并且不要在内存序上找收益(RMW 五档同价)。

判据三(哪一类操作):这个原子变量的热点操作是 load、store 还是 RMW?

  • load → 三档随便挑,指令与时间都一样(§3.3);按语义需要写,不用为性能纠结;
  • store → 这是唯一有代价的一档:relaxed / release 约 0.4 ns,seq_cst 约 2.2 ns(§4.2);
  • RMW → 五档同价(§3.5)。

判据四(要不要全序):需要"多个线程对多个原子操作的先后有一致看法"吗?

  • 不需要 → release / acquire 配对就够(发布-订阅的标准写法);
  • 需要 → seq_cst。而且代价只出现在 store 上,load 与 RMW 上写 seq_cst 不多花东西;
  • 注意标准的限制(§1.3):seq_cst 的顺序一致承诺只在全程都用 seq_cst 时成立。混用弱序会让这个承诺失效——这是无锁代码最容易出错的地方。

判据五(要不要 fence):屏障的位置与原子操作的位置重合吗?

  • 重合 → 不要额外写 fence,两者在 x86 上落到同一批指令(§6.2);
  • 不重合(需要在原子操作之外的位置插屏障)→ 用 atomic_thread_fence;
  • 只在信号处理器、内存映射 I/O 这类场景 → 才考虑 volatile(§7.4)。

判据六(怎么验证):写完怎么知道对不对?

  • 不能靠"跑了很多次没出问题"。§5.2 那个 2.6% 的重排命中率说明:relaxed 下的错误大约每 35 次出现一次,但只在特定交错下出现——"跑了三年"完全可能是运气;
  • 本机无 TSan(§0.3),所以本篇给的是一份手工核对清单(§9.3),不是自动检测方案。

9.2 常见误用清单

说白了,把本篇实测能直接支持的五条误用列出来,按代价的来源归类,每条配一句"该怎么做":

误用 为什么是误用 该怎么做
用 volatile 做线程同步 实测它不生成任何屏障指令(§7.1),不保证原子性、不建立 happens-before 用 std::atomic
在 fetch_add 上把 seq_cst 改成 relaxed 求性能 RMW 五档生成的指令逐字节相同(§3.5),时间也相同(§4.2) 按语义需求写,别指望省时间;要省就减少 RMW 次数
以为"用了 seq_cst 就顺序一致" 标准明确:只有全程都用 seq_cst 才保证(§1.3) 混用弱序时要逐个同步点验证 happens-before
在 store 上无脑用默认的 seq_cst 这是唯一有代价的一档,实测约 5 至 8 倍(§4.2) 确认后续可见性需求;只用 release 语义时写 release
把"x86 上 barrier 都是空操作"当通例 acquire/release fence 确实是空操作,但 seq_cst fence 是一条带锁指令(§6.1) 按操作类型分别判断,不要一句话概括

9.3 手工核对清单(无 TSan 时的替代)

判据六提到本机没有 TSan。下面这份清单是按本篇读数整理的手工核对步骤,用它可以查出一类最常见的错误:

  1. 列出所有共享变量,逐个标注:普通变量 / atomic / 锁保护。
  2. 给每个共享变量画出读写它的线程(哪几个线程写、哪几个线程读)。
  3. 对每个"一个线程写、另一个线程读"的变量,找出它们之间的同步点(哪个 release 配哪个 acquire,或哪把锁)。
  4. 检查是不是"两个原子变量互相等待"的形态(SB 结构)——是的话,§5.2 的 2.6% 就在你头上。
  5. 检查有没有"用 volatile 当同步"——有就改掉。
  6. 检查 relaxed 的用法:每次都问"这条 relaxed 需要和谁建立 happening-before 吗",需要就换成 release/acquire。
  7. 最后看反汇编:把你以为"应该带屏障"的位置编译一遍,看指令里有没有 lock 或 xchg。这是本机唯一可用的自动检查——它不能证明对,但能证明"这里根本没同步"。

十、收束与下一篇钩子

10.1 全篇读数汇总

OK,把本篇测出的全部读数集中在一张表里,便于对照与复核。每一档的原始日志都随篇留档在 evidence/ 下,表里任何一个数字都可以逐字回溯到某一行的日志。

全篇读数汇总面板:反汇编、单线程、lock 成本、重排命中率、竞争、布局六组共 24 项关键读数
图 10-3738 全篇读数汇总面板:反汇编、单线程、lock 成本、重排命中率、竞争、布局六组共 24 项关键读数@ 本机实测(MSVC 19.44 + GCC 16.2)
反汇编(指令 · 条数 · 字节)
load(三档相同)              mov                        1  2
store relaxed / release       mov                        1  2
store seq_cst                 xchg(隐含 lock)           1  2
fetch_add(五档相同)          lock xadd + mov            2  6
CAS(五档相同)                mov + lock cmpxchg + sete  3  10
mfence 出现次数                0(两套工具链共 22 个组合)

单线程 ns/op(5,000 万次)
                            MSVC            GCC
load relaxed / seq_cst      0.4970 / 0.2699  0.3692 / 0.3967
store relaxed / seq_cst     0.4030 / 2.1699  0.4923 / 2.1970
fetch_add relaxed / seq_cst 2.0392 / 2.2205  2.0839 / 2.0623
CAS relaxed / seq_cst       2.5982 / 2.3134  2.4371 / 2.4949

lock 的成本(ns/op)
普通 add                    0.2589          0.2259
atomic store relaxed        0.2645          0.3067
atomic store seq_cst        2.0629          2.1538
fetch_add relaxed           2.0279          2.2378

重排命中率(1,000 万轮,r1==0 && r2==0)
relaxed                     261,643(2.616430%)  306,941(3.069410%)
seq_cst                     0                    0

竞争(2,000 万总操作,ns/op)
fetch_add 1 / 8 线程        1.90→14.37       2.33→15.11
CAS 自旋 2 / 8 线程         16.57→47.00      16.49→42.70

布局
atomic<int>/<long long>/<short>/<char>/<bool>  4/8/2/1/1(两家相同)
atomic_flag                 MSVC 4 B         GCC 1 B

10.2 两套工具链的证据形态

本篇的结论敢把两套工具链并列,靠的是每一处判断都有不依赖时间的证据兜底。

证据形态 MSVC 侧 GCC 侧
反汇编转储 dumpbin /disasm(另用 /FAcs 取机器码字节) objdump -d -M intel
指令与字节计数 从 .asm 的机器码列统计 从 objdump 编码列统计(均可剔除 nop 与 ret)
计时 进程内 steady_clock,多轮 同左
重排统计 两线程 store-buffering,计数命中 同左
布局转储 sizeof / alignof 静态断言 同左

10.3 复现流程

好了,复现这一篇不需要任何私有环境:两条工具链(MSVC 19.44 与 g++ 16.2,都要 C++17)、一组"操作 × 内存序"的独立函数、以及 dumpbin / objdump。全部原始日志与反汇编转储随篇留档于 evidence/。

三个提醒:GCC 侧必须显式加 -pthread(ucrt64 用 winpthreads);计数时必须剔除对齐填充的 nop 与函数尾 ret;重排统计每轮要重建线程,复用线程会把测量本身变成一次同步。

10.4 自检清单

内存序自检清单:十一条,覆盖要不要用原子、RMW 次数、操作类型、全序需求、fence 位置、volatile 边界与证据留档
图 10-3839 内存序自检清单:十一条,覆盖要不要用原子、RMW 次数、操作类型、全序需求、fence 位置、volatile 边界与证据留档@ 本系列绘制
  • [ ] 确认这个变量真的被多个线程访问(不是就别用 atomic)
  • [ ] 确认能不能不用 RMW(本地累加 + 定期汇总,收益最大)
  • [ ] 确认热点操作是 load / store / RMW 里的哪一类
  • [ ] store 上没有无脑用默认的 seq_cst(这是唯一有代价的一档)
  • [ ] fetch_add / CAS 上没有为了性能改内存序(五档同价)
  • [ ] 检查有没有"两个原子变量互相等待"的形态(SB 结构)
  • [ ] 混用弱序时逐个同步点验证 happens-before(全序承诺会失效)
  • [ ] 没有用 volatile 做线程同步
  • [ ] 需要屏障的位置先确认它是一个原子操作(重合就不要写 fence)
  • [ ] 编译后看反汇编确认有 lock 或 xchg(这是本机唯一可用的自动检查)
  • [ ] 全部原始输出留档(否则前面十条都只是"相信")

10.5 与系列其他篇目的交叉点

言归正传,这篇不是孤立的,它与系列里四篇有直接接口:

  • 与 ep01《对象布局、对齐与 padding》:§8.4 那张布局表接着 ep01 的话题——除 atomic_flag 外没有额外开销,说明标准库把原子包装做成了零开销;而 atomic_flag 的 ABI 差异属于 ep01 讲的对齐与填充问题。
  • 与 ep05《优化器的边界》:§7.2 观察编译器重排与 ep05 的 UB 边界同族。编译器屏障(asm volatile("" ::: "memory") 一类)与硬件屏障是两件事——前者是给编译器的,后者是给 CPU 的,volatile 只做前者。
  • 与 ep09《异常与 RTTI》:两篇合起来是"语言机制留下的痕迹"三笔账。ep09 量的是"语言替你留了什么",本篇量的是"语言向你承诺了什么、机器实际给了什么"。两篇里都出现了"默认值是安全的那一个、也是贵的那一个"(noexcept 的默认、seq_cst 的默认)。
  • 与 ep08《分发的代价》:ep08 §6.5 量过 typeid 比较 0.76 ns。本篇的 load(relaxed) 是 0.37 至 0.50 ns——同量级。这说明"一次原子读"并不比"一次类型比较"贵,贵的是 RMW 与带锁的 store。

10.6 一条可以搬走的方法

说白了,把本篇的做法抽象成三步:

  1. 先把一个单词的答案拆成可分类的问题。 「seq_cst 免费吗」拆成 load / store / RMW 三类;不拆开,就只能得到一个适用于三分之一情况的答案。
  2. 把"可能"变成"频率"。 StoreLoad 重排"可能发生"是共识,但只有 2.6% 这个数字能支持判断;统计式实证的成本很低(千万轮几秒钟),收益是让读者可以做决定。
  3. 读指令时要问"这条指令做了什么",不只是"有几条"。 本篇 §3.5 那处最典型:seq_cst store 与 relaxed store 字节数相同、条数相同,代价却差 5 倍——只看长度会得出"没差别"的错误结论。

这三步与 ep07、ep08、ep09 的方法论是同一条。这一篇多了两种结构证据:指令的编码与语义对照,以及统计式实证的命中率。

10.7 参考资料(含源码与证据)

10.8 下一篇:缓存与布局

这一篇算完了语言与机器之间那份契约的第三笔账。第三波到此收官。

下一篇原本属于第四波,也是全系列里证据通道最难的一篇:缓存与布局——AoS/SoA、伪共享与 cache line 的账。

难在哪里,本篇其实已经把话说了一半:§0.3 声明了本机无 perf、无 VTune,所以给不出 cache-miss 事件计数;§8.3 那张表里"缓存行争抢约 13 ns"是从端到端时间间接观察到的,不是从 PMU 计数器读出来的。下一篇要讲伪共享,而伪共享的教科书证据恰恰是 perf c2c 的 HITM 计数——本机拿不到。

所以下一篇的边界会比这一篇更硬:能测的是内存布局对时间的影响(AoS 与 SoA 的对照、填充对齐前后的对照),测不了的是产生这个影响的那次 cache miss。这两件事必须分开说清楚,否则又是一篇"引用别人的 PMU 数字"的文章。

以上。希望能对大家有所帮助。下一篇见。