C++内存模型基石
# 40.C++内存模型基石
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. 多核 CPU 缓存架构
- 4. MESI 协议的状态机
- 5. Store Buffer 与内存重排
- 6. Invalidate Queue 与可见性延迟
- 7. 从硬件到 C++ 内存模型
- 8. 各 CPU 架构的内存序全景
- 9. 汇编证据:store buffer 与无效队列的可见影响
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 Peterson 锁的诡异失效
某嵌入式团队为新 SoC 写了一个自旋锁——用经典的 Peterson 算法(纯软件互斥,不用硬件 CAS)。在 x86 验证通过,换到 ARM 板子上——两个线程同时进入临界区:
// ====== 事故代码 V1:Peterson 锁在 ARM 上失效 ======
std::atomic<bool> flag[2] = {false, false};
std::atomic<int> turn = 0;
// 线程 0 线程 1
void lock0() { void lock1() {
flag[0].store(true, relaxed); flag[1].store(true, relaxed);
turn.store(1, relaxed); turn.store(0, relaxed);
while (flag[1].load(relaxed) while (flag[0].load(relaxed)
&& turn.load(relaxed) == 1); && turn.load(relaxed) == 0);
} }
// 期望:两个线程永不同时进入临界区
// ARM 实测:偶尔同时进入——Peterson 锁失效!
2
3
4
5
6
7
8
9
10
11
12
13
根因:ARM 处理器上的 store 指令可以重排——flag[0]=true 和 turn=1 这两个 store 的顺序可能在另一个核心的视角里是反的。CPU 的 store buffer 让写操作延迟对其他核心的可见——即使这条 store 在程序里写在前面,另一个核心可能先看到后面的 store。
relaxed 内存序不提供任何跨线程的顺序保证——编译器和 CPU 都可以任意重排 relaxed 操作的顺序。
# 1.2 标志位反转的先写后读崩溃
同一个团队的数据消费——生产者先写 data、再写 flag。消费者先读 flag、再读 data。用 relaxed:
// ====== 事故代码 V2:relaxed 的先写后读失效 ======
int data = 0; // 普通变量
std::atomic<bool> ready = false;
// 生产者 // 消费者
data = 42; while (!ready.load(relaxed));
ready.store(true, relaxed); std::cout << data; // ← 可能输出 0!
2
3
4
5
6
7
即使消费者已经看到 ready == true——data 仍然可能是 0。不是编译器优化的问题——是 CPU 在硬件层面重排了 store 的可见性顺序。 当 CPU 0 执行 ready=true 时,这条写可能在 CPU 1 看到 data=42 之前就已经被 CPU 1 观察到。
# 1.3 七个待解疑问
① CPU 缓存 L1/L2/L3 是怎么组织的? cache line 是什么? → 第 3 章
② MESI 协议是什么? 四个状态怎么切换? 为什么需要它? → 第 4 章
③ Store Buffer 是什么? 为什么 CPU 需要它? 它导致了什么重排? → 第 5 章
④ Invalidate Queue 是什么? 为什么导致可见性延迟? → 第 6 章
⑤ C++11 内存模型怎么把这些硬件特性抽象成可移植的规则? → 第 7 章
⑥ x86 和 ARM 的内存序有什么不同? 为什么 x86 上 relaxed 常等于 seq_cst? → 第 8 章
⑦ 怎么在汇编层看到 store buffer 和 invalidate queue 的影响? → 第 9 / 第 10 章
2
3
4
5
6
7
# 2. 架构概览
# 2.1 从 C++ 代码到 CPU 执行的五层下落
C++ 源代码
│
┌────▼─────┐
│ ① 编译器 │ → 指令重排、常量折叠、死代码消除
│ 优化 │ (as-if rule: 单线程行为不变)
└────┬─────┘
│
┌────▼─────┐
│ ② CPU │ → Store Buffer → Store-Load 重排
│ 微架构 │ Invalidate Queue → 可见性延迟
└────┬─────┘
│
┌────▼─────┐
│ ③ 缓存 │ → MESI 协议 → 缓存行状态切换
│ 一致性 │ 总线嗅探 → 多核同步
└────┬─────┘
│
┌────▼─────┐
│ ④ 互连 │ → Ring/Mesh → NUMA 延迟差异
│ 网络 │ 跨 socket → 远程内存访问
└────┬─────┘
│
┌────▼─────┐
│ ⑤ 物理 │ → DRAM 访问 60-100ns
│ 内存 │
└──────────┘
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
C++ 内存模型是第 ① 和第 ②/③ 层之间的契约——它告诉编译器「在什么条件下可以重排我的代码」,同时通过 FENCE 指令告诉 CPU「在这里停下、把所有挂起的写操作提交」。
# 2.2 为何这么切
疑惑:为什么 C++ 需要内存模型——不能直接暴露 x86/ARM 的硬件语义吗?
论证:
- 不同 CPU 架构有根本不同的内存序——x86 是 TSO(Total Store Order),只有 Store-Load 重排。ARM 是 Relaxed 内存模型——四类重排(Store-Store / Load-Load / Load-Store / Store-Load)都存在。如果 C++ 直接暴露硬件语义——同一份代码在 x86 和 ARM 上的行为完全不同。
- 编译器也在重排你的代码——
relaxed编译器可以任意重排(只要单线程 as-if 规则允许)。acquire/release编译器不能把读写移过这个边界。内存序告诉编译器和 CPU「你在这里不能越界」——两层约束、一层抽象。 - 反向验证:C++03 没有内存模型——只能用
volatile和各种平台特定的asm屏障。结果就是std::atomic在 boost 里靠汇编实现、不可跨平台。
结论:C++ 内存模型是把不同 CPU 架构的内存序统一抽象为 6 个 memory_order 标签。写一次代码、编译器在不同架构上生成不同的 fence 指令——这是可移植并发的基石。
# 3. 多核 CPU 缓存架构
# 3.1 L1/L2/L3 的拓扑与延迟
Core 0 Core 1
┌──────────────┐ ┌──────────────┐
│ Execution │ │ Execution │
│ Unit │ │ Unit │
├──────────────┤ ├──────────────┤
│ Store Buffer │ │ Store Buffer │
├──────────────┤ ├──────────────┤
│ L1 Cache (32KB) ~1ns │ L1 Cache │
│ L2 Cache (256KB) ~3ns │ L2 Cache │
├──────────────┤ ├──────────────┤
│ Invalidate │ │ Invalidate │
│ Queue │ │ Queue │
└──────┬───────┘ └──────┬───────┘
│ 共享 L3 │
└────────────┬────────────────┘
│ ~12ns
┌────▼─────┐
│ L3 Cache │ (8-32MB, 共享)
└────┬─────┘
│ ~40ns
┌────▼─────┐
│ DRAM │ (主内存 60-100ns)
└──────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
关键:每个 Core 有自己的 L1/L2——两个 Core 的 L1 之间不直接通信。所有跨核的缓存同步都通过共享的 L3 或总线协议(MESI)。
# 3.2 cache line 与 false sharing
struct Counters { int a; int b; }; // a 和 b 在同一条 cache line (64B) 内
Counters c;
// 线程 0:不停读 c.a 线程 1:不停写 c.b
// c.a 和 c.b 在同一条 cache line:
// 线程 1 写 c.b → MESI 让缓存行失效 → 线程 0 的读触发重新加载
// → 尽管 c.a 没有变——false sharing——性能下降 10-100×
2
3
4
5
6
7
# 3.3 缓存一致性的直观理解
缓存一致性的核心问题:当 Core 0 写了一个值,Core 1 如何「看到」这个新值?
答案——没有中央控制器。每个 Core 通过总线协议广播自己的写操作,其他 Core 听到广播后使自己对应的缓存行失效。一致性的保证是一套分布式的协商协议——MESI。
# 4. MESI 协议的状态机
# 4.1 四个状态的语义
每条 cache line 在任意时刻处于四种状态之一:
Modified (M):该 cache line 只在本核,已被修改(dirty),与主存不一致
本核可以写——不需要通知任何人
Exclusive (E):该 cache line 只在本核,没有被修改(clean),与主存一致
本核可以写——写后切为 M——不需要通知任何人
Shared (S):该 cache line 可能在其他核也有副本(clean),与主存一致
本核读——不改变状态
本核写——需要 RFO (Request For Ownership) → M
Invalid (I):该 cache line 无效——被其他核的写操作广播失效了
本核读写都 miss——必须从 L3/主存/其他核加载
2
3
4
5
6
7
8
9
10
11
12
13
14
# 4.2 状态转换的完整触发条件
当前状态 → 触发事件 → 新状态
I → E (本核读 miss,且没有其他核持有该 line)
I → S (本核读 miss,且其他核持有该 line——shared)
E → M (本核写 hit——不需要通知任何人)
S → M (本核写 hit——需要 RFO 广播→其他核切为 I)
M → S (其他核读 hit——提供数据→shared)
E → S (其他核读 hit——提供数据→shared)
S → I (其他核写——接收 RFO→自己切 I)
M → I (其他核读——提供数据→自己切 S→其他核写→切 I)
2
3
4
5
6
7
8
9
10
# 4.3 为什么 CPU 不能无限等确认
RFO 的流程——Core 0 要写一个 M 状态的 cache line:
① Core 0 发 RFO 广播 → 告诉所有其他核「我要独占这条 cache line」
② 所有其他核 ACK → Core 0 收到全部 ACK
③ Core 0 把 cache line 切为 M → 执行写操作
如果没有 store buffer → 步骤 ② 可能花费 ~100 cycles
→ CPU 在等 ACK 的 100 个周期里什么都不敢做——管道干等
→ store buffer 就是为这个等待发明的(第 5 章)
2
3
4
5
6
7
# 4.4 MESI 为什么能保证一致性
疑惑:MESI 没有中央协调器——两个核同时发 RFO 会怎样?
论证——硬件层的分布式共识:
Core 0 和 Core 1 同时发 RFO 广播,争抢同一条 cache line:
① 两个信号同时在总线上传播
② 总线仲裁器(硬件电路)检测冲突 → 物理层串行化
→ 只允许一个信号「胜出」(先到先得——但「同时」的电学定义是微妙而精确的)
③ 胜出的核的 RFO 先被其他核看到 → 其他核的 cache line 被标记为 I
④ 另一个核的 RFO 到达时 → cache line 已经是 I → 重新请求
结果:双重 M 不会出现——物理层的串行化是最终仲裁。
2
3
4
5
6
7
8
9
这是分布式共识的硬件版本——不需要中央控制器,通过「广播+ACK」机制在物理层保证全局一致性。软件层的 atomic RMW 本质上是同一机制的上层接口——lock cmpxchg 的背后就是总线仲裁的串行化。
# 5. Store Buffer 与内存重排
# 5.1 store buffer 的物理位置与作用
Store Buffer 位于 Core 和 L1 Cache 之间——写操作先进 store buffer,不等 ACK 直接继续执行后续指令:
Core 执行 store A = 1;
→ A=1 进入 Core 的 store buffer(不等 RFO ACK)
→ Core 继续执行 load B;
→ load B 从 L1 缓存中读到值(可能是旧值——因为 store buffer 里的 A=1 还没提交)
Store-Load 重排:后面的 load 可能先于前面的 store 完成!
因为 store 在 buffer 里等待——load 直接从缓存读——不需要等 store 提交
2
3
4
5
6
7
# 5.2 Store-Load 重排的经典例子
int a = 0, b = 0;
// Core 0 // Core 1
a = 1; b = 1;
int r1 = b; int r2 = a;
// 期望:不可能 r1==0 && r2==0(至少有一个人先写)
// 实际:x86 上可以出现 r1==0 && r2==0!
//
// 根因:Core 0 的 a=1 在 store buffer 里等待 L1 的 a 缓存行失效 ACK
// 这段时间里 Core 0 执行 r1=b → 从 L1 读 b(Core 1 的 b=1 可能还没失效 Core 0 的 b)
// → r1 读到了旧值 0!同样 Core 1 也读到了旧值 a=0
2
3
4
5
6
7
8
9
10
11
12
# 5.3 x86 的 TSO 模型为什么只允许 Store-Load 重排
x86 是 TSO(Total Store Order) 模型——所有核看到的所有 store 的顺序是一致的(total order)。但 store 可以和非 wait 的 load 重排。
x86 允许的重排:只有 Store-Load(store buffer 造成)
x86 不允许的重排:Store-Store(store 之间有序)、Load-Load、Load-Store
ARM 允许的重排:四类全部都存在
2
3
4
# 6. Invalidate Queue 与可见性延迟
# 6.1 为什么需要 invalidate queue
当 Core 0 收到 RFO 广播(「我要失效你的 cache line」):
① Core 0 需要把自己的 cache line 标记为 I
② 操作本身很快——但不一定立即影响 Core 0 正在执行的 load
如果没有 invalidate queue → Core 0 必须等 cache line 完全失效后才发 ACK
→ Core 1 在等 ACK——两个核都在等——延迟叠加
为了解决:把失效请求放进 Invalidate Queue
→ Core 0 立即 ACK(不等实际失效完成)
→ 延迟从 ~100 cycles 降到 ~10 cycles
→ 代价:Core 0 后续的 load 可能在 cache line 实际失效之前就执行了
2
3
4
5
6
7
8
9
10
# 6.2 可见性延迟的量化
| 事件 | 延迟 | 说明 |
|---|---|---|
| L1 读 hit | 1 ns (~4 cycles) | — |
| L2 读 hit | 3 ns (~12 cycles) | — |
| 跨核 cache line 迁移(M→I→E) | 20-50 ns | MESI 全流程 |
| 跨 socket cache line 迁移 | 80-150 ns | NUMA 远程访问 |
| 主存访问 | 60-100 ns | DRAM |
# 7. 从硬件到 C++ 内存模型
# 7.1 C++11 之前的可移植并发困境
// C++03 的「并发」——完全依赖平台
#ifdef __GNUC__
__sync_synchronize(); // GCC 的 full memory barrier
#elif _MSC_VER
_ReadWriteBarrier(); // MSVC 的编译器屏障(不是 CPU 屏障!)
#endif
// 每种平台不同的 asm barrier——不可移植
2
3
4
5
6
7
C++11 统一了这一切——std::atomic + std::memory_order 把平台差异封装在标准库中。
# 7.2 内存模型的三组核心关系
① Sequenced-before (SB) —— 同一线程内的代码顺序
单线程内的求值顺序——标准第 [intro.execution] 定义
② Happens-before (HB) —— 跨线程的事件顺序
如果 A happens-before B → A 的所有副作用对 B 可见
③ Synchronizes-with (SW) —— acquire/release 配对
release store SW acquire load → release 之前的所有写对 acquire 之后可见
2
3
4
5
6
7
8
Happens-before 是核心——所有 memory_order 的意义最终都归结为「建立 happens-before 关系」。
# 7.3 从硬件内存序到 std::memory_order 的映射
| memory_order | x86 指令 | ARM 指令 |
|---|---|---|
relaxed | mov (无 fence) | str/ldr (无 fence) |
acquire | mov (隐含 Load-Load + Load-Store 有序) | ldr; dmb ishld |
release | mov (隐含 Store-Load + Store-Store 有序) | dmb ish; str |
seq_cst | mfence (或 lock xchg) | dmb ish (全屏障) |
acq_rel | mov (隐含全部有序,仅剩 Store-Load) | dmb ish |
x86 的优势:强内存模型——大部分操作不需要显式 fence。ARM 几乎每次 acquire/release 都需要 dmb。
# 8. 各 CPU 架构的内存序全景
# 8.1 x86-TSO、ARM、PowerPC 的对比表
| 重排类型 | x86 | ARM (Cortex) | PowerPC |
|---|---|---|---|
| Store-Store | ❌ 不允许 | ✅ 允许 | ✅ 允许 |
| Load-Load | ❌ 不允许 | ✅ 允许 | ✅ 允许 |
| Load-Store | ❌ 不允许 | ✅ 允许 | ✅ 允许 |
| Store-Load | ✅ 允许 (Store Buffer) | ✅ 允许 | ✅ 允许 |
# 8.2 为什么 x86 上 relaxed 和 seq_cst 常常没区别
x86 的 Load 自带 acquire 语义(不允许后续 Load/Store 重排到前面)。 x86 的 Store 自带 release 语义(不允许前面的 Load/Store 重排到后面)。
所以在 x86 上——只有两种操作需要显式 fence:
- atomic RMW(lock xadd 等):隐含全屏障
- seq_cst store:需要
mfence(或xchg)
relaxed load 和 acquire load 在 x86 上生成的汇编通常一样——因为 Load 自带有序。
# 8.3 ARM 上的 dmb 与 acquire/release 的真实代价
std::atomic<int> x;
// relaxed store: str r1, [x] — 1 条指令
// release store: dmb ish; str r1, [x] — 2 条指令——多一条屏障
// 屏障的代价 ≈ 20-50 cycles(等待 store buffer 排空)
2
3
4
5
6
# 9. 汇编证据:store buffer 与无效队列的可见影响
# 9.1 Store-Load 重排在 x86 上的汇编重现
int a=0, b=0;
// Core 0: mov [a], 1; mov r1, [b] → 没有 fence,可以重排
// Core 1: mov [b], 1; mov r2, [a]
// 结果:r1=0 && r2=0 是可能的(Store-Load 重排)
//
// 如果改成: mov [a], 1; mfence; mov r1, [b] → 不可能 r1=r2=0
2
3
4
5
6
7
# 9.2 ARM 上的四类重排重现
ARM 上 str [x], 1; str [y], 1 在另一核可能看到 y=1, x=0——Store-Store 重排。x86 不会。
# 10. 综合案例串讲
# 10.1 案例真相揭晓
| # | 疑问 | 答案 |
|---|---|---|
| ① | CPU 缓存架构? | 第 3 章:L1/L2 每核独立、L3 共享、cache line 64B |
| ② | MESI? | 第 4 章:M/E/S/I 四状态——跨核同步——RFO 广播 |
| ③ | Store Buffer? | 第 5 章:写操作不等 ACK 先缓冲——导致 Store-Load 重排 |
| ④ | Invalidate Queue? | 第 6 章:失效请求异步处理——导致可见性延迟 |
| ⑤ | C++ 内存模型抽象? | 第 7 章:6 个 memory_order——编译器+CPU 双屏障 |
| ⑥ | x86 vs ARM? | 第 8 章:x86 强一致性(仅 S-L 重排),ARM 弱一致性(四类全有) |
| ⑦ | 汇编证据? | 第 9 章:x86 Store-Load 重排重现、ARM dmb 屏障指令 |
案例①修复(Peterson 锁):把所有 relaxed 换成 seq_cst(或至少 acquire/release 配对)。
案例②修复(标志位):ready.store(true, release) + ready.load(acquire)——release 保证 data=42 在 ready=true 之前对所有观察者可见。
# 10.2 一次写操作的完整可见性旅程
Core 0: a.store(42, release);
Core 1: a.load(acquire);
═══════ Core 0 视角 ═══════
① 编译期:release → 编译器不能把前面的写重排到 store 后面
② Store Buffer:a=42 进入 store buffer
③ MESI:广播 RFO → 其他核失效 a 的 cache line → 收到全部 ACK
④ 提交:a=42 写入 L1 cache line(状态=M)
═══════ Core 1 视角 ═══════
⑤ acquire load → 不能在 a.load 之后重排前面的 load
⑥ a 的 cache line 已被失效(Core 0 的 RFO)→ 读 miss
⑦ 向 Core 0 发请求 → 获取 a 的最新值(M→S 迁移)
⑧ 读 a = 42 → 同时所有 release 之前的写对 Core 1 也可见了
2
3
4
5
6
7
8
9
10
11
12
13
14
# 10.3 设计哲学回扣
哲学 1:硬件不是「所见即所得」——CPU 在执行你写的代码之前已经重排了它
Store Buffer 和 Invalidate Queue 是 CPU 设计师为了性能而加入的两层缓冲区。它们不是 bug——是硬件为并行性付出的代价。 一台 5GHz 的 CPU 等一次 RFO ACK 就是 100 个周期——没有 store buffer,这 100 个周期里流水线是完全停滞的。C++ 内存模型的存在就是让程序员在不了解每款 CPU 微架构细节的前提下,写出正确的并发代码——但同时理解硬件是写出高效并发代码的前提。
哲学 2:x86 的「仁慈」是暂时的——弱内存模型是未来的趋势
x86 的强一致性(TSO)让很多并发代码「碰巧通过」。但 Apple M 系列、AWS Graviton、手机芯片——都是 ARM 弱内存模型。如果你的并发代码只依赖 relaxed 在 x86 上的「碰巧有序」——迁移到 ARM 时就是定时炸弹。 正确的做法:在暴露弱一致性最明显的 ARM 平台上测试你的并发代码。
哲学 3:C++ 内存模型是把硬件差异抽象为 6 个可移植的标签
6 个 memory_order 的根本意义:让同一份 C++ 并发代码在不同的 CPU 架构上生成不同的 fence 指令——但始终保持相同的 happens-before 保证。x86 上 acquire 退化为 mov(零开销),ARM 上 acquire 生成 dmb ishld(20-50 cycles)。这是「零开销抽象」在并发领域的终极体现:在不需要屏障的架构上不生成屏障指令。
哲学 4:理解硬件才能理解为什么需要 acquire/release——它们是 Store Buffer 和 Invalidate Queue 的精确控制
为什么 release store + acquire load 能建立 happens-before?因为 release 在硬件层是「排空 store buffer + 让所有之前的写对其他核可见」。acquire 在硬件层是「等 invalidate queue 处理完再读」。acquire/release 不是概念游戏——是对 store buffer 和 invalidate queue 的精确控制指令。
哲学 5:MESI 是全分布式的缓存同步——没有中央协调
MESI 不是「有一个中央仲裁器告诉所有核谁有哪条 cache line」——而是每个核通过总线监听(snoop)其他核的请求,独立修改自己的缓存行状态。这是一个纯粹的分布式协议——一致性来自局部规则(每个核的状态机)遵守全局约定(广播+ACK)。
# 10.4 速查表合集
x86 vs ARM 指令映射:
| C++ | x86 | ARM |
|---|---|---|
load(relaxed) | mov | ldr |
load(acquire) | mov (隐含有序) | ldr; dmb ishld |
store(relaxed) | mov | str |
store(release) | mov (隐含有序) | dmb ish; str |
RMW | lock xadd (全屏障) | ldrex/strex 或 ldaddal |
下一篇:CPU 缓存和 MESI 是硬件基础。下一篇进入 41.六大内存序详解——relaxed/consume/acquire/release/acq_rel/seq_cst 每种内存序的精确语义、happens-before 与 synchronizes-with 的证明、acquire-release 配对的正确姿势、什么时候用 relaxed 是安全的。