多核并发与屏障

一 内存屏障概念

屏障(fence)的核心作用是两件事:保证指令顺序跨线程可见性。两者听起来像一回事,但实现层面是分开的。

  1. 编译器屏障(compiler fence):只阻止编译器重排指令,不产生任何 CPU 指令。asm volatile("" ::: "memory") 就是典型例子,零运行时开销。
  2. 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <atomic>
#include <iostream>

// 对齐到缓存行,避免 false sharing
alignas(64) std::atomic<int> tail{0};
alignas(64) int head{0}; // head 只被消费者访问,不需要原子变量
int buffer[1024];

void producer() {
int t = tail.load(std::memory_order_relaxed);

buffer[t % 1024] = 42;

// release:保证 buffer 的写入在 tail 更新之前对其他核可见
// ARM 上生成 STLR 指令
tail.store(t + 1, std::memory_order_release);
}

void consumer() {
// acquire:读到 tail 的新值后,能看到 producer 在 release 之前写的所有内容
// ARM 上生成 LDAR 指令
while (tail.load(std::memory_order_acquire) <= head) {
// 自旋等待时可以加 CPU hint,让流水线让出资源给其他超线程
// ARM: __builtin_arm_yield()
// x86: _mm_pause()
}

int value = buffer[head % 1024];
std::cout << "Data: " << value << std::endl;

head++; // head 是消费者私有的,直接递增即可
}

这里有几个细节值得注意:

  • tailstd::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,省不了多少性能,却能坑死自己。