内存屏障与架构差异 —— x86 强序 vs ARM 弱序
承 memory-model(总纲)和 memory-model-orders(原子操作与六种内存序)。本篇回答两个落地问题:
std::atomic的内存序在机器上到底编译成什么(内存屏障指令),以及为什么同样的代码在 x86 和 ARM 上性能差这么多(强序 vs 弱序 + 实测数据)。
一、内存屏障是什么
内存屏障(memory barrier / fence)是一条 CPU 指令,用来限制内存访问的重排:屏障之前的内存操作和屏障之后的内存操作,必须按边界分开。
为什么需要它?因为 memory-model-orders 讲的内存序是语言层语义,落到硬件上要靠屏障指令来实现——编译器把 release/acquire 翻译成对应的屏障(或等效的原子指令),来压住 CPU 的乱序和 store buffer/invalidate queue 的重排(这两个队列的机制见 store-buffer / invalidate-queue)。
二、三类屏障指令
| 屏障 | 全称 | 限制什么 |
|---|---|---|
lfence | load fence | 之后的加载不能重排到它之前(压住读重排) |
sfence | store fence | 之后的存储不能重排到它之前(压住写重排,含 store buffer 排空) |
mfence | full fence | 所有加载+存储都不能跨越(全屏障) |
C++ 里直接暴露这些指令的是内建函数(
_mm_lfence/_mm_sfence/_mm_mfence);更常用的做法是用内存序让编译器自动生成,而不是手写内联汇编。
屏障在做什么:排空,不是暂停
说"屏障限制重排"有点抽象,落到队列上更具体:
sfence排空 store buffer:执行 sfence 时,store buffer 里所有未提交的 store 必须先写完(提交到 L1/内存),之后的 store 才允许开始——sfence 是"把异步的写变成同步的节点"。lfence等待 invalidate queue:load 的执行不等待其他核的失效消息(invalidate queue 会拖后腿),lfence 强迫 CPU 先处理完队列里的失效,再继续后面的 load——避免读到"本该失效的旧值"。mfence两者都要:既排空 store buffer,又等 invalidate queue 处理完。
这也是为什么屏障在 x86 上不便宜:它打断了"异步流水线",让核停下来等慢速的缓存/互联。在热循环里滥用会显著拖慢程序——这两个队列的完整机制见 store-buffer / invalidate-queue。
编译屏障 vs CPU 屏障:两层防线
"屏障"这个词其实覆盖两层不同的约束,容易混淆:
| 编译屏障(compiler barrier) | CPU 屏障(hardware barrier) | |
|---|---|---|
| 约束对象 | 编译器(不把内存操作移过边界) | CPU 乱序执行 + store buffer / 失效队列 |
| 例子 | asm volatile("" ::: "memory")、atomic_signal_fence | mfence、dmb ish 等指令 |
| 效果 | 阻止编译器优化重排 | 硬件层面按边界排队执行 |
编译屏障常被误以为"够了"——它拦得住 GCC 的优化,拦不住 CPU 的乱序。C++ 的 atomic_thread_fence 通常两层都做(生成指令 + 阻止编译器重排);而 atomic_signal_fence 只约束编译器,用于信号处理这种"没有其他 CPU 参与"的场景。
三、C++ 的显式屏障:atomic_thread_fence
语言层面的对应物是 std::atomic_thread_fence——它不依附于某个原子操作,独立插在两个普通操作之间:
#include <atomic>
// 编译器/CPU 屏障:让两侧的内存操作按序
std::atomic_thread_fence(std::memory_order_acquire);
std::atomic_thread_fence(std::memory_order_release);
std::atomic_thread_fence(std::memory_order_seq_cst);生产-消费者中的应用
std::atomic<int> buffer[10];
std::atomic<int> count = 0;
void producer(int idx, int value) {
buffer[idx] = value; // 普通写(载荷)
std::atomic_thread_fence(std::memory_order_release); // 发布屏障
count.fetch_add(1, std::memory_order_relaxed); // 计数器可用 relaxed
}
int consumer(int idx) {
int current;
do {
current = count.load(std::memory_order_relaxed);
} while (current == 0);
std::atomic_thread_fence(std::memory_order_acquire); // 获取屏障
return buffer[idx]; // 一定读到最新值
}要点:载荷是普通变量(buffer 甚至不需要是原子),屏障负责"把载荷的写/读钉在计数器的两侧"。这比给每个载荷都加原子操作便宜得多——只有当"计数器"是原子时,才用它做同步媒介。
一个判断技巧:凡看到"普通变量 + 屏障"的写法,说明作者在刻意省原子操作的锁开销;看到"每个访问都带内存序",通常是简单但略贵的写法。两者没有绝对好坏,取决于载荷是不是热点。
屏障 vs 操作附带的内存序
操作附带序(store(release)) | 独立屏障(atomic_thread_fence(release)) | |
|---|---|---|
| 位置 | 附着在单个原子操作上 | 独立指令,可插在任意位置 |
| 保护范围 | 只对该操作的前后 | 两侧所有内存操作 |
| 常见度 | 高(推荐优先用) | 低(特殊场景,如无锁队列) |
内存屏障和内存序的关系:内存序是语义,屏障是实现手段之一。x86 上
release store通常编译成普通mov(x86 硬件本身不重排 store-store),ARM 上则生成stlr指令。真正的"屏障指令"主要在弱序架构上出现。
四、x86 与 ARM 内存模型差异
1. x86:强顺序模型
- 硬件层面不允许 Load-Load、Load-Store、Store-Store 重排;
- 只允许 Store-Load 重排(store buffer 导致:写还没落内存,就读到了旧值);
- 因此大多数场景不需要显式屏障,编译器就能满足 acquire/release 语义;
seq_cst store才需要额外mfence(或xchg)。
2. ARM:弱顺序模型
- 允许各类重排(Store-Load、Load-Load、Load-Store、Store-Store 都可能发生);
- 必须显式屏障才能保证顺序:
ldar(load-acquire)/stlr(store-release)指令,或dmb ish全屏障; - 屏障还有多级作用域:
dmb ish(inner shareable,同 cluster 核间)、dmb osh(outer)、dmb sy(全系统)——多 Socket 场景选错作用域是隐藏 bug。
3. 差异对比
| 操作 | x86 | ARM |
|---|---|---|
| 加载(acquire) | 普通 mov 即天然 acquire | ldar(load-acquire) |
| 存储(release) | 普通 mov 即天然 release | stlr(store-release) |
| 全屏障 | mfence / lock 前缀 | dmb ish 等 |
| 默认强弱 | 强序(只允许 Store-Load) | 弱序(全类型可能重排) |
一句话:x86 是"硬件帮你挡了大半",ARM 是"软件自己负责"。写跨平台代码时,如果只在 x86 上验证过,换到 ARM 可能悄悄踩坑——这正是内存模型文档存在的意义。
五、第三视角:不是只有 x86/ARM 两种
把视野拉宽一点,内存模型是一条连续的谱,不只是两极:
| 架构 | 模型 | 代表特点 |
|---|---|---|
| x86/AMD64 | 强序(TSO) | 只允许 Store-Load 重排,store buffer 是主要来源 |
| ARMv8 / RISC-V | 弱序(RCsc/RVWMO) | 全类型可重排,必须 ldar/stlr 或 fence 指令 |
| PowerPC | 弱序(RCpc) | 更弱,连部分 load 都可能乱序,历史上最"任性" |
对写代码的人,结论只有一条:内存模型是给硬件"松绑"的空间,越弱的模型,软件要承担的责任越多。C++ 的六种内存序是跨架构的抽象——同一份代码在强序上"恰好对",不代表在弱序上对。测试覆盖不了所有架构时,按最弱模型的语义写,永远最安全。
六、量化:不同内存序的性能开销
1. 测试环境
- 硬件:Intel Xeon Platinum 8375C @ 2.90GHz / ARM Cortex-A72
- 系统:Linux 5.15.0-78-generic;编译器:gcc 11.3.0
2. 测试方法
双线程各执行 100 万次 fetch_add,对比不同内存序耗时(完整代码与测量函数见 memory-model.md 第五节):
| 内存序 | x86 耗时 (us) | ARM 耗时 (us) | 性能比 (x86:ARM) |
|---|---|---|---|
seq_cst | 124500 | 238000 | 1 : 1.91 |
acquire | 89200 | 156000 | 1 : 1.75 |
release | 87600 | 152000 | 1 : 1.73 |
relaxed | 76300 | 121000 | 1 : 1.58 |
3. 结论
seq_cst比relaxed慢约 30%(x86 上 12.45ms vs 7.63ms)——这就是"默认序"的代价,频繁执行的热路径值得考虑降级。- ARM 整体比 x86 贵 1.6~1.9 倍:弱序架构虽然理论上"更宽松",但原子操作/屏障指令本身成本更高(且 Cortex-A72 本身是较老的核心)。
- 能省则省:无同步需求的计数用
relaxed;同线程内、无跨线程共享的变量甚至不用原子。原子操作比普通内存访问慢得多(本数据是 7.6ms vs 普通自增通常 <1ms),别滥用。
七、跨平台开发提醒
- 默认
seq_cst起步,性能瓶颈出现且确认语义后再降级——降级必须逐处分析 happens-before,不是全局替换。 - 同一份代码两种架构都要测:x86 验证过的并发逻辑,弱序架构上可能行为不同(经典的例子见 reordering-overview 的实验)。
- 尽量别手写屏障:用
std::atomic+ 内存序,让编译器/标准库按平台生成正确的指令;手写mfence/dmb意味着放弃可移植性。
一句话总结
内存序落到硬件靠屏障:x86 强序只禁 4 类重排中的 3 类、几乎免屏障,ARM 弱序全类型可重排、必须 ldar/stlr/dmb;实测 seq_cst 比 relaxed 慢约 30%、ARM 原子操作整体比 x86 贵近一倍——所以"默认用 seq_cst,热路径才降级"。