本文旨在辨析并发编发中的常见核心概念,目的是防止初学者在学习过程中对相关概念一知半解,互相混淆,越学越懵。本文以澄清概念为主,对部分知识点,比如 MESI 缓存一致性协议,不会深入介绍,感兴趣的读者请自行学习。
一、背景:CPU 多级缓存架构
为了读者在阅读后序章节时,有更清晰、更形象的认知,这里放上现代 CPU 缓存的典型结构。一图胜千言,不多赘述,仅陈述以下要点:
- 一颗 CPU,往往有多个核心(core)。同一时刻,每个 core 上都可以运行一个线程(thread)。
- 为了追求更高的效率:
- 编译器 / CPU 可能重排某些指令,把后面的提前执行。
- CPU 增加了多级缓存。
这使得某些情况下,某个线程看到的数据,可能是非预期的,进而导致程序出现逻辑错误。
当然,这并不是说编译器或者 CPU 的设计有缺陷,而是一种平衡与妥协:为了效率 , 编译器 / CPU 会适度"放宽政策",不做过于严苛的约束和检查,这在多数情况下是安全的;为了正确性,在并发编程时,开发者需要更加精细的干预。
🔍 点击查看图片
二、概念辨析
1. 缓存一致性(Cache Coherence)
层次:纯硬件机制,在 CPU 内部固化。
解决的问题 :同一个内存地址(单一变量) 在多个 CPU 核心的本地缓存(L1/L2 Cache)中如何同步。即,一个核心的写入如何让其它核心感知?
plain
核心0的L1缓存: [addr X] = 1 ← 我刚写入
核心1的L1缓存: [addr X] = 0 ← 这里还是旧值,怎么同步?
机制 :MESI、MOESI、MESIF协议(状态机)。注意,这些缓存一致性协议是固化在CPU内部的,纯硬件实现。
plain
M (Modified) - 该 cache line 仅存在当前 cache 中,与内存不一致(dirty),其他 CPU core 缓存无效
E (Exclusive) - 该 cache line 仅存在当前 cache 中,与内存一致(clean)
S (Shared) - 该 cache line 被多核共享,且与内存一致
I (Invalid) - 该 cache line 已失效,需重新加载
关键特性:
- 单地址/变量
- 最终一致,但不保证何时能看到(因为有 store buffer)。
- 不保证多个地址之间的对外可见顺序(核心1可能先看到先看到修改后的变量A,后看到修改后的变量B;核心2可能正好赶过来)。
- 对程序员完全透明,你不需要也不能直接操控它。
通俗理解:它是底层的"群消息同步机制",保证群里所有人看到的单条消息内容是一致的。
2. 内存一致性模型(Memory Consistency Model)
层次:硬件架构规范层,由 CPU 架构决定,不同类型的 CPU 不一样。
解决的问题:多个地址上的读写操作,从其它核心观察时,顺序会不会乱?允许哪些重排?
这才是真正决定"多线程程序正确性"游戏规则的模型:
plain
允许的重排
模型 Load-Load Load-Store Store-Store Store-Load
───────────────────────────────────────────────────────────────────────────────────────
Sequential Consistency (SC) × × × ×
Total Store Order (TSO) × × × ✓ ← 只允许这个
Relaxed / Weak Consistency ✓ ✓ ✓ ✓
不同架构的选择:
- x86/x64:TSO(Total Store Order)------接近最强,只允许 Store→Load 重排。
- ARM:非常宽松(weakly ordered),几乎允许所有重排。
- POWER:类似 ARM,甚至更宽松。
缓存一致性 vs 内存一致性模型的区别:
- 缓存一致性回答:一个地址的写入最终会传播吗?答:会(最终所有核心一定能看到一致结果)
- 内存一致性模型回答:多个地址的操作,以什么顺序传播?是否允许后操作的先被看到?
通俗理解:你发了消息 A,又发了消息 B。内存一致性模型决定了,群里其他人有没有可能先看到 B,后看到 A。
3. 内存屏障(Memory Barrier / Fence)
层次:硬件指令 + 编译器指令
解决的问题 :在代码的特定位置,强制约束 重排序边界。是程序员/编译器用来干预"内存一致性"的物理武器。
类型:
- Load Barrier:屏障前的所有 load 操作,必须在屏障后的任何 load 操作开始之前,全局完成(对其他处理器可见)。
- Store Barrier:屏障前的所有 store 操作,必须在屏障后的任何 store 操作开始之前,全局完成(从 Store Buffer 刷新到 L1 Cache,并对其他处理器可见)
- Full Barrier: 屏障前的所有读和写操作,必须在屏障后的任何读和写操作开始之前,全局完成。
具体指令:
plain
x86:
LFENCE → Load Barrier
SFENCE → Store Barrier
MFENCE → Full Barrier(最常用)
LOCK前缀 → 隐含 Full Barrier
ARM:
DMB ISH → Full Barrier(数据内存屏障)
DSB ISH → 更强的同步屏障
ISB → 指令同步屏障(刷流水线)
两种屏障(注意区分):
cpp
// 编译器屏障(只防止编译器重排,CPU不受限)
asm volatile("" ::: "memory"); // GCC
_ReadWriteBarrier(); // MSVC
// 硬件屏障(同时防止编译器重排 + CPU重排)
asm volatile("mfence" ::: "memory"); // x86 Full Barrier
4. 内存序(Memory Order)
层次:C++ 编程语言层,C++11 引入。
本质 :是对内存屏障的高级抽象,让程序员用语义而非汇编指令来表达需求。
机制 :你写下memory_order,编译器会根据当前的 CPU 架构(x86 还是 ARM),自动帮你翻译成对应架构的内存屏障指令(Memory Barrier)。
六个级别:
plain
relaxed → 只保证原子性,不产生任何屏障
acquire → Load 时用:屏障后的操作不能重排到此 Load 之前
release → Store 时用:屏障前的操作不能重排到此 Store 之后
acq_rel → 用于 RMW:同时具备 acquire + release 语义
consume → acquire 的弱化版(实践中几乎不用)
seq_cst → 最强:全局顺序一致,等价于 Full Barrier
编译器如何翻译:
plain
C++ memory_order x86 生成 ARM 生成
─────────────────────────────────────────────────────
relaxed load → MOV LDR
relaxed store → MOV STR
acquire load → MOV LDAR
release store → MOV STLR
seq_cst store → MOV + MFENCE STLR + DMB
seq_cst load → MOV LDAR
x86 上 acquire/release 不需要额外指令(因为 TSO 已经提供了大部分保证),ARM 上需要专用指令。
5. 四者关系
🔍 点击查看图片
它们的依赖关系:
- 缓存一致性 :硬件底座,没有它,写入根本无法传播,其他一切无从谈起。
- 内存一致性模型 :硬件架构契约与规则,定义了默认允许什么、禁止什么。
- 内存屏障 :硬件指令,是工具和手段:当默认规则不够用时,用它来强化约束。
- 内存序 :是软件层高级抽象,C++ 程序员通过它告诉编译器需要什么保证
三、内存序/内存屏障的作用范围
作用1:防止当前线程内的指令重排(编译器 + CPU)
cpp
// 没有屏障,编译器和CPU可能重排这两条指令
data = 42; // 可能被移到 flag store 之后!
flag = true;
// 有 release 屏障,data = 42 一定在 flag = true 之前完成
data = 42;
flag.store(true, memory_order_release); // 屏障
作用2:控制跨线程的可见性时序
通过控制"store 何时变得全局可见 "和"load 何时看到最新值 "来影响其他线程的观察结果。
plain
内存屏障的物理效果(以 x86 TSO 为例):
CPU Core 0: Store Buffer Cache(共享)
─────────────────────────────────────────────────────────────
store data = 42 → 进入 Store Buffer → [等待提交]
store flag = true → 进入 Store Buffer → [等待提交]
MFENCE → 强制刷新 Store Buffer → data=42, flag=true 提交到 Cache
CPU Core 1: Cache(共享)
─────────────────────────────────────────────────────────────
load flag ← 从 Cache 读(必须看到 flag=true 后才能继续)
MFENCE / acquire ← 确保后续 load 看到最新 Cache 状态
load data ← 从 Cache 读 → 一定是 42
关键点:
- 内存屏障强制刷新 Store Buffer,让 store 提交到 Cache(对其他核心可见)
- 缓存一致性协议(MESI)负责在 Cache 之间传播这个更新
- acquire load 确保从 Cache 读时看到最新状态(不使用过期的缓存行)
所以:
plain
防重排(编译器/CPU内部)+ 强制可见性(跨线程)
↑ ↑
同一个机制,两种效果,不可分割
四、Store-Load 重排
1. 原因
根本原因:Store Buffer(写缓冲区)
当 CPU 执行写操作(Store)时,如果直接写入L1 Cache,由于多核之间的"缓存一致性协议(如MESI)",CPU 必须等待其他核心确认并作废它们对应的缓存行,这个等待过程比较漫长(CPU 视角)。为了不阻塞 CPU,核心会先把数据写到 Store Buffer 中,然后继续执行后续指令。
plain
现代 CPU 架构:
CPU Core
↓ store
[Store Buffer] ← store 先写这里(速度快,不用等Cache响应)
↓ 异步刷新
[L1 Cache]
↓
[L2 Cache]
↓
[LLC / 内存]
plain
问题场景(Dekker 互斥算法的经典失败案例):
初始值:X = 0, Y = 0
Thread 1: Thread 2:
store X = 1 store Y = 1
load R1 = Y load R2 = X
期望:R1=1 或 R2=1 至少有一个成立
实际:R1=0 且 R2=0 竟然可能发生!(x86上也会!)
时序解析:
plain
Time →
Thread 1: store X=1 → [Store Buffer] ← 还没提交到 Cache!
Thread 1: load Y=0 ← 从 Cache 读(Y 的 store 还在 Thread2 的 Store Buffer 里)
Thread 2: store Y=1 → [Store Buffer] ← 还没提交到 Cache!
Thread 2: load X=0 ← 从 Cache 读(X 的 store 还在 Thread1 的 Store Buffer 里)
// 结果:R1=0, R2=0。两个 store 都"消失了"
Store-Load 重排的本质:不是 CPU 真的"调换了顺序",而是 store 在 store buffer 里异步等待,而 load 已经直接去 cache 读了。效果上等价于 load 跑到了 store 之前。
Store-Load 重排的理解 :从外部观察者 (其它 CPU core)的视角看,load 跑到 store 的前头了。因为从外部观察者的立场来看,store完成的标志,是"你得让我看见" 。现在,在没有让我看到你写的值的情况下,你先执行了后面的 load 指令,那对我来说,你就是先读后写了。
注意:x86/TSO 只允许 Store→Load 重排,其他三种(Load-Load, Load-Store, Store-Store)x86 不允许。ARM 四种都允许。
2. 解决方案
方法1:在 store 和 load 之间插入 Full Barrier
cpp
// x86
asm volatile("mfence" ::: "memory");
// C++ 标准方式
std::atomic_thread_fence(std::memory_order_seq_cst);
plain
Thread 1:
store X = 1
MFENCE ← 强制刷新 Store Buffer,X=1 提交到 Cache
load R1 = Y ← 此时 Y 的最新值一定可见
Thread 2:
store Y = 1
MFENCE
load R2 = X ← 此时 X=1 一定可见
方法2:使用 seq_cst 原子操作
cpp
std::atomic<int> X{0}, Y{0};
// Thread 1
X.store(1, std::memory_order_seq_cst); // 含隐式 Full Barrier
int r1 = Y.load(std::memory_order_seq_cst);
// Thread 2
Y.store(1, std::memory_order_seq_cst);
int r2 = X.load(std::memory_order_seq_cst);
// 保证:r1=1 或 r2=1 至少一个成立