你写了一段多线程代码:线程 A 先写 data 再写 flag,线程 B 等待 flag 后读 data。逻辑上,flag=1 时 data 一定已经就绪。但在某些 CPU 上,线程 B 可能看到 flag=1 却读到 data 的旧值——这不是 bug,而是 CPU 的内存排序(Memory Ordering)行为。
内存排序是并发编程中最微妙的话题之一。理解它,才能写出正确的多线程代码;不理解它,你的程序在 x86 上正确但在 ARM 上可能出错。
一、为什么会有内存排序?
1.1 编译器重排
编译器在优化时可能重排指令:
// 源代码data = 42;flag = 1;
// 编译器可能重排为(如果 data 和 flag 没有依赖关系)flag = 1;data = 42;编译器只保证单线程语义不变——在单线程视角下,两种顺序结果相同。但多线程视角下,另一个线程可能看到 flag=1 但 data 还是 0。
1.2 CPU 重排
CPU 的 Store Buffer(见第 7 章)也会导致内存重排:
1.3 四种内存重排
| 重排类型 | 含义 | 示例 |
|---|---|---|
| Store-Store | 两个写操作重排 | data=42; flag=1 → flag=1; data=42 |
| Load-Load | 两个读操作重排 | 读 flag 后读 data → 先读 data 后读 flag |
| Store-Load | 写后读重排 | flag=1; return data → 先读 data 后写 flag |
| Load-Store | 读后写重排 | 读 data 后写 flag → 先写 flag 后读 data |
二、内存模型:从最强到最弱
2.1 内存模型谱系
2.2 顺序一致性(SC)
最严格的模型:所有核心看到所有操作的全局顺序,且顺序与程序顺序一致。
在 SC 模型下,不存在任何内存重排。但 SC 严重限制硬件优化,现代 CPU 都不实现 SC。
2.3 x86 TSO(Total Store Order)
x86 的内存模型是 TSO,允许的唯一重排是 Store-Load:
| 重排类型 | x86 TSO | 原因 |
|---|---|---|
| Store-Store | 不允许 | 写入按 FIFO 顺序从 Store Buffer 刷出 |
| Load-Load | 不允许 | 读取按程序顺序 |
| Store-Load | 允许 | Store Buffer 导致写后读可能重排 |
| Load-Store | 不允许 | 读取不会延迟后续写入 |
x86 的 TSO 是”较强”的内存模型——只允许 Store-Load 重排。这意味着在 x86 上,很多并发代码”碰巧”能正确运行,但移植到 ARM 上可能出错。
2.4 ARM/RISC-V 弱序模型
ARM 和 RISC-V 的内存模型允许所有四种重排:
| 重排类型 | ARM/RISC-V | 原因 |
|---|---|---|
| Store-Store | 允许 | Store Buffer 不保证 FIFO |
| Load-Load | 允许 | Invalidate Queue 导致读重排 |
| Store-Load | 允许 | Store Buffer |
| Load-Store | 允许 | 读写可重叠 |
2.5 三种模型的对比
| 属性 | SC | x86 TSO | ARM 弱序 |
|---|---|---|---|
| Store-Store | 保证 | 保证 | 不保证 |
| Load-Load | 保证 | 保证 | 不保证 |
| Store-Load | 保证 | 不保证 | 不保证 |
| Load-Store | 保证 | 保证 | 不保证 |
| 需要屏障 | 无 | SFENCE/LFENCE/MFENCE | DMB/DSB/ISB |
三、内存屏障
3.1 内存屏障的类型
| 屏障类型 | 作用 | 防止的重排 |
|---|---|---|
| LoadLoad | 之前的读在之后的读之前完成 | Load-Load |
| StoreStore | 之前的写在之后的写之前完成 | Store-Store |
| LoadStore | 之前的读在之后的写之前完成 | Load-Store |
| StoreLoad | 之前的写在之后的读之前完成 | Store-Load |
| 全屏障(Full) | 以上全部 | 全部 |
3.2 x86 的内存屏障指令
| 指令 | 作用 | 对应屏障 |
|---|---|---|
SFENCE | Store 屏障 | StoreStore |
LFENCE | Load 屏障 | LoadLoad + LoadStore |
MFENCE | 全屏障 | 全部四种 |
在 x86 TSO 下,大多数场景不需要屏障——因为只有 Store-Load 可能重排。只有需要防止 Store-Load 重排时才需要 MFENCE。
3.3 ARM 的内存屏障指令
| 指令 | 作用 | 对应屏障 |
|---|---|---|
DMB(Data Memory Barrier) | 数据内存屏障 | 全屏障 |
DSB(Data Synchronization Barrier) | 数据同步屏障 | 全屏障 + 等待完成 |
ISB(Instruction Synchronization Barrier) | 指令同步屏障 | 刷新流水线 |
ARM 的 DMB 比 x86 的 MFENCE 更频繁使用——因为 ARM 允许更多重排。
3.4 屏障的代价
| CPU | MFENCE/DMB 延迟 | 说明 |
|---|---|---|
| x86 (Intel) | ~20-40 周期 | 排空 Store Buffer |
| ARM (Cortex-A76) | ~20-50 周期 | 排空 Store Buffer + Invalidate Queue |
| ARM (Cortex-A53) | ~30-80 周期 | 顺序核心,屏障开销更大 |
四、C++ memory_order
4.1 六种内存序
C++11 的原子操作支持六种内存序:
| memory_order | 保证 | 对应硬件 |
|---|---|---|
relaxed | 无排序保证 | 无屏障 |
consume | 数据依赖排序 | 很少硬件支持,通常等同于 acquire |
acquire | 之后的读/写不能重排到此之前 | LoadLoad + LoadStore 屏障 |
release | 之前的读/写不能重排到此之后 | LoadStore + StoreStore 屏障 |
acq_rel | acquire + release | 全屏障 |
seq_cst | 全局顺序一致 | 全屏障 + 全局顺序协议 |
4.2 Release-Acquire 语义
最常用的模式:Release 写入 + Acquire 读取构成同步关系。
#include <atomic>
int data = 0;std::atomic<int> flag{0};
// 线程 A(生产者)void producer() { data = 42; // 普通写入 flag.store(1, std::memory_order_release); // Release 写入 // 保证 data=42 在 flag=1 之前对其他线程可见}
// 线程 B(消费者)void consumer() { while (flag.load(std::memory_order_acquire) != 1) // Acquire 读取 ; // flag=1 的 Acquire 保证之后读到 data 的最新值 assert(data == 42); // 一定成功!}4.3 memory_order 的硬件映射
| C++ memory_order | x86 实现 | ARM 实现 |
|---|---|---|
relaxed | 普通 mov | 普通 ldr/str |
acquire | 普通 mov(x86 读有 acquire 语义) | ldr + DMB ISH |
release | 普通 mov(x86 写有 release 语义) | DMB ISH + str |
acq_rel | 普通 mov | DMB ISH + str + DMB ISH |
seq_cst | mov + MFENCE(store)/ lock 前缀 | DMB ISH + str + DMB ISH |
x86 的 TSO 模型使得 acquire/release 几乎免费——普通读写就具有 acquire/release 语义。ARM 的弱序模型则需要显式屏障,这就是为什么同一份并发代码在 ARM 上比 x86 慢。
4.4 双重检查锁定模式
class Singleton { static std::atomic<Singleton*> instance; static std::mutex mtx;
public: static Singleton* getInstance() { Singleton* tmp = instance.load(std::memory_order_acquire); if (tmp == nullptr) { std::lock_guard<std::mutex> lock(mtx); tmp = instance.load(std::memory_order_relaxed); if (tmp == nullptr) { tmp = new Singleton(); instance.store(tmp, std::memory_order_release); } } return tmp; }};五、Linux 内核的内存屏障
5.1 内核屏障 API
| API | 作用 | 对应硬件 |
|---|---|---|
barrier() | 编译器屏障 | 无硬件指令 |
smp_rmb() | 读屏障 | LFENCE / DMB |
smp_wmb() | 写屏障 | SFENCE / DMB |
smp_mb() | 全屏障 | MFENCE / DMB |
smp_load_acquire() | Acquire 读取 | 取决于架构 |
smp_store_release() | Release 写入 | 取决于架构 |
5.2 编译器屏障 vs 硬件屏障
// 编译器屏障:阻止编译器重排,但不阻止 CPU 重排barrier(); // Linux 内核__asm__ __volatile__("" ::: "memory"); // GCC
// 硬件屏障:同时阻止编译器和 CPU 重排smp_mb(); // Linux 内核编译器屏障和硬件屏障的区别经常被忽视。在单线程代码中,编译器屏障足够;但在多线程代码中,必须使用硬件屏障。
六、常见的内存排序陷阱
6.1 陷阱 1:Dekker 算法在 ARM 上失败
// Dekker 互斥算法(在 x86 上正确,在 ARM 上可能失败)std::atomic<int> flag0{0}, flag1{0}, turn{0};
// 线程 0flag0.store(1, std::memory_order_relaxed);while (flag1.load(std::memory_order_relaxed)) { if (turn.load(std::memory_order_relaxed) != 0) { flag0.store(0, std::memory_order_relaxed); while (turn.load(std::memory_order_relaxed) != 0); flag0.store(1, std::memory_order_relaxed); }}// 临界区...在 ARM 的弱序模型下,relaxed 操作可能被重排,导致两个线程同时进入临界区。修复:使用 memory_order_acq_rel 或 seq_cst。
6.2 陷阱 2:自旋锁的实现
// 错误的自旋锁(在 ARM 上可能失败)void lock(std::atomic<int> &mutex) { while (mutex.exchange(1, std::memory_order_relaxed)) { // relaxed 不保证临界区内的写入对其他线程可见 }}
// 正确的自旋锁void lock(std::atomic<int> &mutex) { while (mutex.exchange(1, std::memory_order_acquire)) { // acquire 保证临界区内的读取看到最新的值 }}
void unlock(std::atomic<int> &mutex) { mutex.store(0, std::memory_order_release); // release 保证临界区内的写入对其他线程可见}七、动手实验
7.1 实验 1:Store-Load 重排(x86)
#include <stdio.h>#include <pthread.h>#include <stdatomic.h>
int x = 0, y = 0;int r1 = 0, r2 = 0;
void *thread_a(void *arg) { x = 1; // Store r1 = y; // Load return NULL;}
void *thread_b(void *arg) { y = 1; // Store r2 = x; // Load return NULL;}
int main() { int count = 0; for (int i = 0; i < 1000000; i++) { x = y = 0; pthread_t t1, t2; pthread_create(&t1, NULL, thread_a, NULL); pthread_create(&t2, NULL, thread_b, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); if (r1 == 0 && r2 == 0) { count++; // Store-Load 重排! } } printf("Store-Load 重排次数: %d / 1000000\n", count); // x86: 可能出现少量重排(Store Buffer 导致) // ARM: 可能出现更多重排}7.2 实验 2:内存屏障的效果
#include <stdio.h>#include <pthread.h>#include <stdatomic.h>
int data = 0;atomic_int flag = 0;
// 无屏障版本void *producer_no_barrier(void *arg) { data = 42; atomic_store(&flag, 1, memory_order_relaxed); return NULL;}
// Release-Acquire 版本void *producer_release(void *arg) { data = 42; atomic_store(&flag, 1, memory_order_release); return NULL;}
void *consumer_acquire(void *arg) { while (atomic_load(&flag, memory_order_acquire) != 1); printf("data = %d\n", data); // Release-Acquire: 一定是 42 return NULL;}八、x86 / ARM / RISC-V 内存模型对比
8.1 三种架构的允许重排
| 重排类型 | x86-64 (TSO) | ARMv8 | RISC-V |
|---|---|---|---|
| Store-Store | 不允许 | 允许 | 允许 |
| Load-Load | 不允许 | 允许 | 允许 |
| Store-Load | 允许 | 允许 | 允许 |
| Load-Store | 不允许 | 允许 | 允许 |
x86 的 TSO 是”强序”模型——只有 Store-Load 一种重排。ARM 和 RISC-V 都是弱序模型,允许所有四种重排,但两者的具体语义有细微差异。
8.2 ARMv8 vs RISC-V 的差异
| 维度 | ARMv8 | RISC-V |
|---|---|---|
| 模型名称 | ”Weakly Ordered” + “Multi-copy Atomicity" | "Weak Memory Ordering” (RVWMO) |
| 多副本原子性 | 有(Store 对所有核心同时可见) | 有(Same-address 原子性) |
| 数据依赖 | 保证(地址/控制/数据依赖不被重排) | 保证(addr/ctrl/data dependency) |
| 屏障指令 | DMB / DSB / ISB | FENCE (rw, rw) |
| Acquire/Release | LDAR / STLR 指令 | FENCE.RW, RW / FENCE.R, RW 等 |
ARMv8 引入了 LDAR(Load-Acquire)和 STLR(Store-Release)指令,比 ARMv7 的 DMB 屏障更轻量。RISC-V 的 FENCE 指令可以细粒度控制读写方向(如 FENCE.RW,RW = 全屏障,FENCE.R,R = LoadLoad 屏障)。
8.3 同一代码在不同架构上的行为
// 经典的 Message Passing 测试int data = 0;int flag = 0;
// 线程 Avoid writer() { data = 42; // Store 1 flag = 1; // Store 2}
// 线程 Bvoid reader() { if (flag == 1) { // Load 1 assert(data == 42); // Load 2 }}| 架构 | assert 可能失败? | 原因 |
|---|---|---|
| x86-64 | 不会 | Store-Store 不重排,flag=1 一定在 data=42 之后可见 |
| ARMv8 (无屏障) | 可能 | Store-Store 可重排,flag=1 可能先于 data=42 可见 |
| ARMv8 (用 STLR) | 不会 | STLR 保证 Store-Store 不重排 |
| RISC-V (无 FENCE) | 可能 | Store-Store 可重排 |
| RISC-V (FENCE.W,W) | 不会 | FENCE.W,W 保证 Store-Store 不重排 |
九、Acquire-Release 语义详解
9.1 Acquire 语义
Acquire 操作保证:之后的读写不能被重排到 Acquire 之前。
┌──────────────────────────────┐Acquire │ 之后的读/写不能越过此线向上 │ └──────────────────────────────┘ ↑ 之前的操作可以自由重排- 对应屏障:LoadLoad + LoadStore
- 典型用途:读取标志位后,保证后续的数据读取看到最新值
9.2 Release 语义
Release 操作保证:之前的读写不能被重排到 Release 之后。
↓ 之后的操作可以自由重排 ┌──────────────────────────────┐Release │ 之前的读/写不能越过此线向下 │ └──────────────────────────────┘- 对应屏障:LoadStore + StoreStore
- 典型用途:写入数据后,保证标志位的写入在数据写入之后可见
9.3 Release-Acquire 同步对
当 Release 写入和 Acquire 读取配对时,它们构成一个同步关系(synchronizes-with):
关键点:Release-Acquire 不是双向屏障。Release 只保证”之前的操作不泄漏”,Acquire 只保证”之后的操作不提前”。它们配对才能建立完整的同步。
9.4 常见的 Acquire-Release 使用模式
// 模式 1:自旋锁void lock(std::atomic<int> &mutex) { while (mutex.exchange(1, std::memory_order_acquire)) { // acquire: 临界区内的读取不会提前到锁获取之前 }}void unlock(std::atomic<int> &mutex) { mutex.store(0, std::memory_order_release); // release: 临界区内的写入不会延迟到锁释放之后}
// 模式 2:一次性初始化std::atomic<int> initialized{0};int data;
void init() { data = compute(); // 普通写 initialized.store(1, std::memory_order_release); // Release}
void use() { if (initialized.load(std::memory_order_acquire)) { // Acquire use_data(data); // 一定看到 compute() 的结果 }}
// 模式 3:生产者-消费者队列(单生产者单消费者)std::atomic<int> tail{0}, head{0};int buffer[SIZE];
void produce(int val) { int t = tail.load(std::memory_order_relaxed); buffer[t % SIZE] = val; tail.store(t + 1, std::memory_order_release); // Release: buffer 写入在 tail 更新之前}
int consume() { int h = head.load(std::memory_order_relaxed); while (tail.load(std::memory_order_acquire) <= h); // Acquire: 读取 buffer 在 tail 读取之后 int val = buffer[h % SIZE]; head.store(h + 1, std::memory_order_release); return val;}十、无锁编程模式
10.1 SeqLock(顺序锁)
SeqLock 是一种读多写少的无锁模式,Linux 内核广泛使用:
// SeqLock:读端无需获取锁std::atomic<int> seq{0};int data_a, data_b;
// 写端(需要互斥)void write(int a, int b) { seq.fetch_add(1, std::memory_order_release); // 序号变为奇数 → 写入中 data_a = a; data_b = b; seq.fetch_add(1, std::memory_order_release); // 序号变为偶数 → 写入完成}
// 读端(无锁)bool read(int *a, int *b) { int s1, s2; do { s1 = seq.load(std::memory_order_acquire); *a = data_a; // 读取数据(可能正在被写入) *b = data_b; s2 = seq.load(std::memory_order_acquire); } while (s1 != s2 || s1 & 1); // 序号变化或写入中 → 重试 return true;}10.2 RCU(Read-Copy-Update)思想
RCU 允许读者无锁访问,写者创建副本后原子替换指针:
// 简化的 RCU 模式struct Data { int values[100];};
std::atomic<Data*> current{nullptr};
// 读者:无锁void reader() { Data* ptr = current.load(std::memory_order_acquire); use(ptr->values); // 使用期间 ptr 不会被释放 // 通过 grace period 保证 ptr 在所有读者退出后才释放}
// 写者:创建副本void writer() { Data* old = current.load(std::memory_order_acquire); Data* new_data = malloc(sizeof(Data)); *new_data = *old; // 复制 new_data->values[0] = 42; // 修改副本 current.store(new_data, std::memory_order_release); // 原子替换 // 等待 grace period 后释放 old synchronize_rcu(); free(old);}无锁编程的正确性极难保证。以上代码是简化版本,生产环境需要处理 ABA 问题、内存回收、grace period 等复杂问题。详见第 15 章:无锁编程。
十一、小结
上一章了解了缓存一致性与 MESI 协议。
| 概念 | 要点 | 对软件的影响 |
|---|---|---|
| 四种重排 | Store-Store, Load-Load, Store-Load, Load-Store | 不同 ISA 允许的重排不同 |
| x86 TSO | 只允许 Store-Load 重排 | 并发代码在 x86 上更容易正确 |
| ARM 弱序 | 允许所有四种重排 | 需要更多显式屏障 |
| Release-Acquire | 最常用的同步模式 | C++ memory_order_release/acquire |
| 内存屏障代价 | 20-80 周期 | 频繁使用屏障会降低性能 |
| 编译器屏障 | 只阻止编译器重排 | 多线程需要硬件屏障 |
下一步:SIMD 与向量化——如何用一条指令同时处理多个数据?SSE/AVX/NEON 指令如何加速你的代码?
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






