多核并发与屏障
一 内存屏障概念
屏障(fence)的核心作用是两件事:保证指令顺序和跨线程可见性。两者听起来像一回事,但实现层面是分开的。
- 编译器屏障(compiler fence):只阻止编译器重排指令,不产生任何 CPU 指令。
asm volatile("" ::: "memory")就是典型例子,零运行时开销。 - CPU 内存屏障(hardware fence):实际生成一条 CPU 指令,阻止处理器乱序执行,并确保 store/load 结果对其他核可见。
两者都需要,缺一不可——即使 CPU 不乱序,编译器也可能把你的代码搬到错误位置。
二 CPU 内存模型对比
| 架构 | 内存模型 | 顺序保证 |
|---|---|---|
| x86 / x86-64 | TSO(Total Store Order) | StoreStore / LoadLoad / LoadStore 有保证,Store→Load 可能乱序 |
| ARM / AArch64 | Weakly Ordered | StoreStore / LoadLoad / StoreLoad 都可能乱序,需要显式 fence |
StoreLoad hazard 是最常见的坑:当前核的 store 还在 store buffer 里没有刷到缓存,后续的 load 却绕过 store buffer 直接从缓存或内存读取,于是读到了旧值。x86 的 TSO 模型里,这是唯一允许的乱序方向;ARM 上四个方向都可能出问题。
解决办法是在写端用 release,读端用 acquire,在两个线程之间建立 happens-before 链。
三 Release / Acquire Fence
配对规则很简单,但很多人记不住,列个表:
| 写入端 | 读取端 | 结果 |
|---|---|---|
| release store | acquire load/fence | ✅ 建立跨线程 happens-before |
| 普通 store | acquire load | ❌ 无法保证可见性 |
| release store | 普通 load | ❌ 读端可能看不到前写 |
release store(写端):在逻辑上声明”这个 store 之前的所有写都完成了”,对应 ARM 上的 STLR 指令。
acquire load(读端):在逻辑上声明”读到这个值之后,才能看后面的操作”,对应 ARM 上的 LDAR 指令。
不要靠 x86 的天然 TSO 顺序写无锁代码——那样的代码在 ARM 上会悄悄出错,而且很难复现。
四 示例
下面是一个单生产者单消费者(SPSC)环形队列的最简版本,用来演示 release/acquire 的典型用法。多生产者或多消费者场景需要额外的 CAS,不在本例讨论范围内。
1 |
|
这里有几个细节值得注意:
tail用std::atomic,因为生产者写、消费者读,存在跨线程访问head只有消费者自己读写,用普通int就够了,不需要原子操作tail的初始 load 用relaxed,因为这里只是读自己上次写的值,不涉及跨线程同步
五 总结
x86 和 ARM 的主要差异不是”要不要写屏障”,而是”CPU 帮你做了多少”:
- x86 TSO:CPU 层面保证了大部分顺序,
memory_order_release/acquire在 x86 上通常不会生成额外的 fence 指令。但编译器屏障依然必要,不能省略——省了之后编译器可能把你的代码重排到完全错误的地方。 - ARM:什么都不帮你保证,每一处需要同步的地方都得显式写出来。好处是性能更可控,坏处是出错了很难调试。
实际写无锁代码的建议:先用 std::memory_order_seq_cst 把逻辑跑通,再在性能瓶颈处换成 release/acquire,最后用 ThreadSanitizer 跑一遍验证。别一上来就写 relaxed,省不了多少性能,却能坑死自己。