第 07 章 内存模型与屏障:编译器和 CPU 都在骗你
前面几章,我们都默认了一个前提:代码是”按写的顺序”执行的。
这一章,我们把这个前提砸碎。
真相是:你写的代码顺序,不是它执行的顺序。
编译器会重排你的指令,CPU 会乱序执行,缓存会让不同核心看到”不同的内存”。你以为的”顺序执行”,是编译器和 CPU 联合起来给你演的一出戏。
这出戏在单线程下天衣无缝(你观察不到任何异常)。但一旦多线程共享数据,戏就穿帮了——而且穿帮的方式极其诡异,诡异到你在 x86 上测一万遍都测不出来,但一上 ARM 就炸。
引子:一个能”证明 CPU 骗了你”的实验
先看一段代码,你能看出它的问题吗?
// 全局变量,初始都为 0
int x = 0, y = 0;
int r1, r2; // 结果
// 线程 1
void thread1() {
x = 1; // (1)
r1 = y; // (2)
}
// 线程 2
void thread2() {
y = 1; // (3)
r2 = x; // (4)
}两个线程同时跑,最后 r1 和 r2 可能是什么值?
直觉告诉你:r1 和 r2 至少有一个是 1(因为总有一个人先写)。
实际上,在弱内存模型的 CPU(比如 ARM、PowerPC)上,r1 == 0 && r2 == 0 是可能出现的!
线程 1 重排:(2) 先执行,读到 y=0;然后 (1) 执行
线程 2 重排:(4) 先执行,读到 x=0;然后 (3) 执行
→ r1 = 0, r2 = 0
两个线程都"看到对方还没写",但实际它们都写了。
这个结果违背了所有直觉——“两个线程都执行完了写操作,但都读到了对方的旧值”。
为什么会这样?因为:
- 编译器可能把”读 y”和”写 x”的顺序交换(因为编译器看不出 x 和 y 有依赖)
- CPU 可能乱序执行(读 y 的指令先于写 x 完成,因为读缓存比写快)
- store buffer 让”写 x”的结果暂时只存在于本 CPU 的缓冲里,其他 CPU 暂时看不到
这一章,我们就来拆穿这出戏,并且搞清楚:在多线程编程里,怎么保证你看到的顺序就是你要的顺序。
给你一句总纲:
内存模型要回答的问题是:一个线程写的数据,另一个线程”什么时候”、“以什么顺序”能看到。
编译器和 CPU 为了性能,会”偷偷”重排你的操作。而内存屏障(memory barrier/fence)是你对抗这种重排的唯一武器——它告诉编译器和 CPU:“这里给我停下来,保持顺序。“
本章路线图
第一幕:为什么代码会被重排
Q1 编译器和 CPU 为什么非要重排你的代码?
Q2 CPU 乱序执行到底是怎么发生的?
Q3 store buffer 是什么?它为什么让"写"变得不可见?
第二幕:缓存一致性
Q4 多核的缓存是怎么保持一致性的?MESI 协议是什么?
Q5 既然有 MESI,为什么还会有"内存可见性"问题?
第三幕:内存屏障
Q6 内存屏障是什么?它到底"屏障"了什么?
Q7 acquire/release 语义是什么?为什么它是锁的根基?
Q8 x86 和 ARM 的内存模型有什么区别?为什么 x86 是"强"的?
第四幕:工程实战
Q9 伪共享(false sharing)是什么?怎么毁掉你的性能?
Q10 Go 的内存模型说了什么?sync.Once 为什么 atomic + mutex?
Q11 这一套知识在你的日常开发里怎么用?
第一幕:为什么代码会被重排
Q1 编译器和 CPU 为什么非要重排你的代码?
答案是:为了性能。
但要理解”为什么重排能提升性能”,得先理解一个残酷的事实:
内存,是整个计算机里最慢的东西之一(相对 CPU)。
CPU 执行一条指令: ~0.3ns
CPU 读 L1 缓存: ~1ns
CPU 读主存: ~100ns(慢 300 倍!)
CPU 读主存(跨 NUMA): ~130ns
而 CPU 主频 3GHz,每纳秒执行 3 条指令
读一次主存的 100ns,CPU 可以执行 300 条指令
所以 CPU 的设计者们绞尽脑汁,就是不让 CPU “干等”主存。
重排,就是这个目标的产物:
编译器的重排:
编译器重排 = 在"不改变单线程语义"的前提下,调整指令顺序
例:
int a = x; // 读内存(慢)
int b = 1; // 不碰内存(快)
int c = 2; // 不碰内存(快)
编译器可能重排成:
int b = 1; // 先做快的
int c = 2;
int a = x; // 把慢的放后面(让 CPU 早点发出读请求)
或者把"两个独立的读"合并,把"读"提前发出(预取),
把"写"延迟(攒着一起写)。
关键:编译器重排的前提是”不改变单线程的观察结果”。 单线程下,你怎么都看不出顺序变了(因为重排只发生在”没有依赖”的操作之间)。
CPU 的乱序执行:
CPU 乱序 = 指令"发射"后,可以不按顺序"完成"
例:
mov eax, [mem1] ; 读 mem1(慢,要等 100ns)
mov ebx, [mem2] ; 读 mem2(快,缓存命中)
add ecx, edx ; 纯寄存器操作(快)
CPU 可以:
先执行 add(因为不依赖内存)
mem2 缓存命中,先完成
mem1 缓存 miss,最后完成
→ 指令的"完成顺序"是 add → 读 mem2 → 读 mem1
而不是你写的"读 mem1 → 读 mem2 → add"
关键:CPU 也保证”单线程语义不变”——它用”乱序执行 + 顺序提交”来保证:虽然内部乱序,但”提交”(对程序员可见的架构状态)是按顺序的(对单线程而言)。
所以总结:
编译器和 CPU 重排的唯一理由,是”榨干性能”。而它们敢重排的前提,是”单线程观察不到”。
问题在于:“单线程观察不到” ≠ “多线程观察不到”。 一旦多线程共享数据,重排就暴露了。
这就是为什么需要内存屏障——它是对”重排”的约束,告诉编译器和 CPU:“这里不能再优化了,必须保持顺序。“
Q2 CPU 乱序执行到底是怎么发生的?
CPU 乱序执行是现代 CPU 的标配,理解它的机制,能帮你理解”为什么需要屏障”。
CPU 的指令流水线(pipeline):
现代 CPU 的流水线(简化):
取指 → 译码 → 执行 → 访存 → 写回
一条指令经过这几个阶段。而"流水线"意味着:
指令 1 在"执行"阶段时,指令 2 已经在"译码",指令 3 在"取指"
→ 多级流水,同时处理多条指令
乱序执行(out-of-order execution):
核心思想:指令"发射"(issue)后,在"执行"阶段不按顺序,
只要"操作数就绪",就立即执行,不管它是第几条指令。
例:
指令 1:读 mem[addr](慢,缓存 miss,要等 100ns)
指令 2:add r1, r2(快,寄存器操作,立即能执行)
指令 3:读 mem[addr2](快,缓存 hit)
乱序执行:指令 2 和指令 3 先执行(它们不依赖指令 1 的结果),
指令 1 最后执行(等缓存 miss 的数据回来)。
为什么这样能提速?
因为 CPU 不”空等”。指令 1 缓存 miss 要等 100ns,这 100ns 里 CPU 不闲着,它先执行指令 2、3。把”等待的时间”填满。
乱序执行的关键组件:
1. 重排序缓冲区(Reorder Buffer,ROB):
记录所有"已发射但未提交"的指令,按程序顺序排序
2. 保留站(Reservation Station):
指令在这里等"操作数就绪",就绪了就执行(不按顺序)
3. 顺序提交(in-order commit):
虽然执行乱序,但"提交"(更新架构寄存器/内存)按程序顺序
→ 保证了"单线程的观察结果"是顺序的
关键:乱序执行 + 顺序提交 = 单线程无感知,但多线程有感知。
乱序执行:指令执行的顺序乱了
顺序提交:指令"生效"的顺序(对单线程可见的顺序)没乱
但"生效"只对"本 CPU 的架构状态"是顺序的,
对"其他 CPU 通过缓存看到的内存"不一定是顺序的!
这就是为什么多线程会看到”乱序”——因为其他 CPU 通过缓存观察到的是”执行的乱序结果”,而不是”提交的顺序结果”。
Q3 store buffer 是什么?它为什么让”写”变得不可见?
store buffer(存储缓冲)是理解”内存可见性”问题的关键,也是”为什么需要屏障”的直接原因。
问题:CPU 写内存太慢,怎么优化?
CPU 写一个变量(比如 x = 1):
1. 要先把 x 所在的缓存行加载到本 CPU 的缓存(如果 miss,要等 100ns)
2. 修改缓存行
3. 可能还要等缓存一致性协议(MESI)把其他 CPU 的缓存行失效
这个"写"的过程可能很慢(尤其缓存 miss 时)。
如果 CPU 每次写都"同步等待"写完成,那 CPU 又要空等了。
优化:store buffer。
CPU 加了一个"store buffer"(存储缓冲):
- CPU 执行"写 x = 1"时,把"写"先放进 store buffer
- 然后 CPU 继续执行后面的指令(不用等写完成)
- store buffer 里的写,稍后"异步地"刷新到缓存/内存
这带来一个致命的问题:写”暂时不可见”。
CPU 0 执行:
x = 1; // (1) 写 x,进入 store buffer(还没真正写进缓存!)
r1 = y; // (2) 读 y
问题:CPU 0 读 y 时,会不会读到"自己还没刷新的 x"?
不会(store forwarding 保证读自己的写)。
但问题在于:CPU 0 的"写 x"在 store buffer 里,还没刷新,
其他 CPU(比如 CPU 1)此时读 x,读到的是旧值 0!
这就是”内存可见性”问题的根源之一:
CPU 0 认为"我已经写了 x = 1"(写进了 store buffer)
但 CPU 1 读 x,还是 0(因为 store buffer 还没刷新到缓存)
store buffer 导致的经典问题(引子里的例子):
线程 1(CPU 0): 线程 2(CPU 1):
x = 1 (进 store buffer) y = 1 (进 store buffer)
r1 = y (读 y,可能=0) r2 = x (读 x,可能=0)
两个线程的"写"都在各自的 store buffer 里,没刷新
两个线程的"读"都读到了对方的旧值
→ r1 = 0, r2 = 0(违反直觉的结果!)
怎么解决?内存屏障。
在"写 x"之后、"读 y"之前,加一个"store-load 屏障":
强制 store buffer 里的写"刷新"到缓存,再执行后面的读
x = 1;
barrier(); // 强制 store buffer 刷空
r1 = y; // 现在读到的 y 是"刷新后"的最新值
总结这一段:
store buffer 是”写延迟”的硬件优化,它让”写”变得”异步”。而异步的代价,是”写的可见性”延迟。
内存屏障的作用之一,就是”强制刷新 store buffer”,让写变得”同步可见”。
第二幕:缓存一致性
Q4 多核的缓存是怎么保持一致性的?MESI 协议是什么?
多核 CPU 的每个核都有自己的 L1/L2 缓存。问题来了:如果 CPU 0 和 CPU 1 的缓存里都有 x 的副本,CPU 0 改了 x,CPU 1 怎么知道?
答案:缓存一致性协议,最经典的是 MESI。
MESI 是缓存行的四种状态的缩写:
M(Modified,已修改):
本 CPU 的缓存行是最新的,且和主存不一致
(本 CPU 改过它,其他 CPU 的副本已失效)
E(Exclusive,独占):
本 CPU 的缓存行是最新的,且和主存一致
(只有本 CPU 有这个副本,其他 CPU 没有)
S(Shared,共享):
本 CPU 的缓存行和主存一致,且其他 CPU 也可能有副本
(多个 CPU 共享只读副本)
I(Invalid,失效):
本 CPU 的缓存行失效了(其他 CPU 改过,本 CPU 的副本过期)
(读这个缓存行要重新从主存/其他 CPU 拿)
MESI 的状态转换(核心规则):
读(load):
本地缓存行是 M/E/S → 直接读
本地是 I(失效)→ 从主存/其他 CPU 读,状态变 S(或 E)
写(store):
本地是 M → 直接写
本地是 E → 写成 M(因为独占,改了就是最新的)
本地是 S → 要先"失效"其他 CPU 的副本(发 Invalidate 消息),
然后写成 M(独占后修改)
本地是 I → 先读进来,再走上面的流程
关键理解:MESI 保证的是”缓存一致性”(cache coherence),不是”内存一致性”(memory consistency)。
缓存一致性(cache coherence):
保证"对同一个内存地址,所有 CPU 最终看到一致的值"
关注的是"单个地址"的一致性
内存一致性(memory consistency):
保证"多个内存操作,对所有 CPU 呈现一致的顺序"
关注的是"多个地址的操作顺序"
MESI 解决了前者,没解决后者。 这就是为什么”有 MESI,仍然有可见性问题”。
Q5 既然有 MESI,为什么还会有”内存可见性”问题?
这是很多人困惑的点:MESI 不是保证缓存一致吗?为什么还会有可见性问题?
答案:因为 store buffer 和 invalidate queue 的存在。
MESI 保证”最终一致”,但 store buffer 和 invalidate queue 引入了”延迟”:
store buffer 的问题(Q3 讲过):
CPU 0 写 x = 1:
MESI 要求:写之前要"失效"其他 CPU 的 x 副本
但"失效"要发消息、等确认,慢
优化:CPU 0 把"写 x = 1"放进 store buffer,
立即执行后面的指令,不等"失效确认"
结果:CPU 0 的"写"暂时没生效,其他 CPU 读 x 还是旧值
→ 可见性延迟
invalidate queue 的问题(store buffer 的”对偶”):
CPU 1 收到"失效 x 副本"的消息:
要"失效"本地的 x 缓存行(把状态改成 I),这也需要时间
优化:CPU 1 把"失效"放进 invalidate queue,
立即回复"收到",延迟"真正失效"
结果:CPU 1 的 x 副本暂时"看起来还有效",
读 x 时可能读到旧值(因为还没真正失效)
→ 又是可见性延迟
所以:
MESI 保证:最终,所有 CPU 的缓存会一致。
store buffer + invalidate queue 引入:暂时的"不一致"。
而"暂时的不一致",在多线程下就可能被观察到,
导致"内存可见性"问题。
核心洞察:
缓存一致性(MESI)保证的是”最终一致”,内存可见性要求的是”及时可见”。
store buffer 和 invalidate queue 是”用及时性换性能”的优化,它们打破了”及时可见”。
而内存屏障的作用,就是强制”及时可见”——刷新 store buffer、处理 invalidate queue,让”最终一致”变成”此刻一致”。
第三幕:内存屏障
Q6 内存屏障是什么?它到底”屏障”了什么?
内存屏障(memory barrier / memory fence) 是一条特殊的指令(或编译指示),它强制”屏障两侧的操作,保持顺序”。
它屏障的是什么?屏障的是”重排”。
没有屏障:
编译器和 CPU 可能重排你的指令
有屏障:
屏障之前的所有内存操作,必须"先于"屏障之后的所有内存操作完成
内存屏障的四种类型(这是核心概念):
1. StoreStore 屏障(写写屏障)
屏障前的写,先于屏障后的写
例:x = 1; StoreStore; y = 2;
→ 保证 x 的写在 y 的写之前"被看见"
2. LoadLoad 屏障(读读屏障)
屏障前的读,先于屏障后的读
例:a = x; LoadLoad; b = y;
→ 保证读 x 在读 y 之前完成
3. StoreLoad 屏障(写读屏障,最强也最贵)
屏障前的写,先于屏障后的读
例:x = 1; StoreLoad; b = y;
→ 保证 x 的写"对所有人可见"后,才读 y
→ 这是"全屏障"(full barrier),最贵
4. LoadStore 屏障(读写屏障)
屏障前的读,先于屏障后的写
例:a = x; LoadStore; y = 1;
→ 保证读 x 完成,才写 y
这些屏障分别对应”禁止哪些重排”:
StoreStore 屏障:禁止"写和写"重排
LoadLoad 屏障:禁止"读和读"重排
StoreLoad 屏障:禁止"写和读"重排(最严格)
LoadStore 屏障:禁止"读和写"重排
在 x86 上的对应指令:
mfence ; StoreLoad 屏障(全屏障,最贵)
lfence ; LoadLoad 屏障
sfence ; StoreStore 屏障
// x86 的锁前缀指令(如 lock add、lock cmpxchg)隐含着全屏障
lock cmpxchg [x], rax ; 原子操作 + 全屏障在 ARM 上的对应:
dmb ; Data Memory Barrier(数据内存屏障)
dsb ; Data Synchronization Barrier(更强)
isb ; Instruction Synchronization Barrier
// ARM 是弱内存模型,需要显式用 dmb 等指令来保证顺序Go 里的内存屏障:
import "sync/atomic"
// atomic 操作隐含了内存屏障
atomic.StoreInt64(&x, 1) // 隐含 StoreStore 屏障
atomic.LoadInt64(&y) // 隐含 LoadLoad 屏障
// sync.Mutex 的 Lock/Unlock 也隐含屏障
mu.Lock() // acquire 语义(隐含 LoadLoad + LoadStore 屏障)
mu.Unlock() // release 语义(隐含 LoadStore + StoreStore 屏障)一个重要的理解:内存屏障不是”防止重排”的开关,而是”给重排划界”。
它不是说”屏障之后不能重排”,而是说”屏障两边的操作,不能跨过屏障重排”。
屏障就像一堵墙:墙左边的操作不能跑到右边,墙右边的不能跑到左边。但墙左边的操作之间,还是可以互相重排的。
Q7 acquire/release 语义是什么?为什么它是锁的根基?
acquire 和 release 是比”内存屏障”更高层的抽象,它们是理解锁的基础。
acquire(获取)语义:
一个"acquire"操作(比如读一个原子变量,或者加锁),
保证:它之后的所有读写,不会被重排到它之前。
acquire 的作用:我"获取"了某个东西,之后的操作都基于它。
例:mu.Lock() 是 acquire,锁之后的代码不会跑到锁之前。
release(释放)语义:
一个"release"操作(比如写一个原子变量,或者解锁),
保证:它之前的所有读写,不会被重排到它之后。
release 的作用:我把某个东西"发布"出去,之前的操作都完成了。
例:mu.Unlock() 是 release,锁之前的代码不会跑到锁之后。
acquire/release 配对,形成”临界区的边界”:
release acquire
↓ ↓
线程 A:写数据 → Unlock Lock → 读数据
↑ ↑
所有写都完成了 能看到线程 A 的所有写
acquire/release 保证:
线程 A 在 Unlock(release)之前的所有写,
在线程 B 在 Lock(acquire)之后都可见。
用具体的例子理解:
var mu sync.Mutex
var data int
// 线程 A(写者)
func writer() {
mu.Lock()
data = 42 // 写数据
mu.Unlock() // release:保证 data = 42 在 Unlock 之前完成
}
// 线程 B(读者)
func reader() {
mu.Lock() // acquire:保证 Lock 之后的操作,能看到 A 的写
fmt.Println(data) // 保证看到 42
mu.Unlock()
}acquire/release 和内存屏障的关系:
acquire 语义 ≈ LoadLoad 屏障 + LoadStore 屏障
release 语义 ≈ LoadStore 屏障 + StoreStore 屏障
(注意:acquire/release 都不包含 StoreLoad 屏障,
这就是为什么它们比"全屏障"便宜)
为什么 acquire/release 是锁的根基?
因为锁的语义就是:
加锁(acquire):获得进入临界区的资格,之后的读能看到之前的写
解锁(release):完成临界区的修改,把结果"发布"给下一个加锁者
lock/unlock 的 acquire/release 配对,
保证了"临界区内的修改"能被"下一个进入临界区的人"看到。
一个优雅的理解:
acquire/release 是”屏障”的语义化。
- 屏障(fence):低层,告诉你”这里不能重排”,但没说”为什么”
- acquire/release:高层,表达了”获取/发布”的意图
工程上,你应该用 acquire/release(或更高层的锁), 而不是手写屏障——因为屏障太容易写错,而 acquire/release 有明确的语义。
Q8 x86 和 ARM 的内存模型有什么区别?为什么 x86 是”强”的?
这是理解”为什么有些 bug 在 x86 上测不出来,在 ARM 上就炸”的关键。
x86 的内存模型:TSO(Total Store Order,完全存储有序)。
x86 是"强内存模型",它的规则是:
1. 读和读不会重排(LoadLoad 自动保证)
2. 读和写不会重排(LoadStore 自动保证)
3. 写和写不会重排(StoreStore 自动保证)
4. 唯一的例外:写和读可能重排(StoreLoad 不保证!)
所以 x86 上:
大部分重排都被硬件禁止了(自动保证顺序)
只有"写→读"可能重排(因为 store buffer)
这就是为什么 x86 是”强”的:硬件帮你在大部分情况下保证了顺序,你只需要担心”写→读”这一种情况。
ARM 的内存模型:弱内存模型。
ARM 是"弱内存模型",它几乎允许所有重排:
1. 读读可能重排
2. 读写可能重排
3. 写写可能重排
4. 写读可能重排
所以 ARM 上:
你必须显式地用屏障(dmb 等)来保证顺序
否则各种重排都可能发生
这带来的实际后果:
同一个多线程程序:
x86 上:可能"碰巧"正确(因为 x86 自动保证大部分顺序)
ARM 上:可能出错(因为 ARM 会重排,需要显式屏障)
所以:
你在 x86 上开发、测试,程序"正常",
部署到 ARM(手机、树莓派、ARM 服务器、Apple Silicon),
程序"诡异出错"——就是内存模型差异导致的。
一个具体的例子(引子里的):
// 线程 1 // 线程 2
x = 1; y = 1;
r1 = y; r2 = x;
x86(TSO):
线程 1 的"写 x"和"读 y",因为 x86 保证 StoreLoad 的例外,
可能重排成"读 y → 写 x"。
但"写 x"和"写 y"不会重排。
实际上,x86 上 r1=0 && r2=0 也可能发生!
(因为 StoreLoad 重排正是 x86 允许的唯一重排)
ARM(弱):
所有重排都可能,r1=0 && r2=0 更容易发生关键:x86 的”强”不等于”没有 bug”。 x86 仍然有”写→读”重排(StoreLoad),这恰恰是引子例子里那个”违反直觉”结果的来源。
工程含义:
1. 不要依赖"我在 x86 上测过没 bug"来证明多线程代码正确
→ ARM 上可能就炸了
2. 用语言提供的内存模型(而不是硬件的)
→ Go 的 happens-before 规则、Java 的 JMM、C++ 的 memory order
→ 用这些高级抽象,编译器/运行时帮你处理硬件差异
3. 测试要覆盖多种架构
→ 尤其如果你的代码会跑在 ARM(手机、ARM 服务器、Mac M 系列)
核心教训:
内存模型是”硬件和语言的契约”。
- 强模型(x86):硬件帮你看住大部分顺序,但牺牲了性能
- 弱模型(ARM):硬件放开重排(性能好),但把”保证顺序”的责任推给软件
而语言(Go、Java、C++)的内存模型,是在”硬件的弱模型”之上,提供一套”可移植的顺序保证”。
所以:写多线程代码,要遵守”语言内存模型”,而不是”具体硬件的巧合行为”。 否则你的代码就是”在 x86 上碰巧对,在 ARM 上必然错”。
第四幕:工程实战
Q9 伪共享(false sharing)是什么?怎么毁掉你的性能?
伪共享是”缓存一致性协议”带来的一个著名性能陷阱,它极其隐蔽。
先理解缓存行(cache line):
CPU 的缓存,是以"缓存行"为单位读写的,一个缓存行 64 字节。
当你读一个变量,CPU 会把"这个变量所在的整个 64 字节"都加载进缓存。
伪共享的场景:
// 两个计数器,每个线程操作一个
type Counters struct {
counter1 int64 // 线程 1 频繁写
counter2 int64 // 线程 2 频繁写
}
var c Counters
// 线程 1
func t1() {
for i := 0; i < 1000000; i++ {
c.counter1++ // 写 counter1
}
}
// 线程 2
func t2() {
for i := 0; i < 1000000; i++ {
c.counter2++ // 写 counter2
}
}问题:counter1 和 counter2 在同一个缓存行里!
counter1 和 counter2 都是 int64,各 8 字节,
它们紧挨着,在同一个 64 字节缓存行里。
线程 1 写 counter1:
MESI 协议:要让 counter1 独占,必须"失效"其他 CPU 的缓存行
但 counter1 和 counter2 在同一行 → 其他 CPU 的 counter2 也失效了
线程 2 写 counter2:
发现自己的缓存行失效了,要重新加载
又要"失效"线程 1 的缓存行(counter1 也失效)
结果:counter1 和 counter2 的缓存行在两个 CPU 之间"乒乓",
每次写都要抢缓存行、失效对方、重新加载。
→ 两个线程本来互不干扰,现在因为"共用缓存行"而疯狂互相失效
→ 性能可能差 10-100 倍
这就是”伪共享”:两个线程操作”不同的变量”,但因为它们在同一个缓存行,导致”虚假的共享”,性能暴跌。
怎么检测?
1. 性能异常:两个线程各写各的,但性能比单线程还差
2. perf 看 cache miss:
perf stat -e cache-misses ./program
如果 cache-misses 异常高,怀疑伪共享
3. 看"缓存行乒乓":
用 perf c2c(cache-to-cache)分析
怎么解决?
// 方案一:填充(padding),让两个变量在不同缓存行
type Counters struct {
counter1 int64
_ [7]int64 // 填充 56 字节,让 counter2 在下一个缓存行
counter2 int64
}
// 方案二:用独立的变量,或者用 atomic 分开
var counter1 atomic.Int64
var counter2 atomic.Int64
// 方案三:Go 里用 struct 对齐到缓存行
type PaddedCounter struct {
val int64
pad [8]int64 // 让 val 独占一个缓存行
}Go 标准库里的真实例子:
Go 的 runtime 里有很多”padding”来避免伪共享:
// runtime/mstats.go 里
type mstats struct {
// ...
_ [sys.CacheLinePadSize]byte // 填充,避免和相邻字段伪共享
}伪共享的教训:
“共享”不一定要”真的共享同一个变量”。物理上”挨得近”(同一缓存行),就会导致性能上的”共享”。
这揭示了一个深刻的道理:性能优化里,你不仅要关心”逻辑上的数据结构”,还要关心”物理上的内存布局”。
这就是为什么高性能库(如 Disruptor、Rust 的 crossbeam)都精心设计内存布局,把”会被不同线程写的字段”分隔到不同的缓存行。
Q10 Go 的内存模型说了什么?sync.Once 为什么 atomic + mutex?
Go 内存模型的核心:happens-before(先行发生)关系。
Go 的内存模型(go.dev/ref/mem)用”happens-before”来定义可见性:
如果事件 A happens-before 事件 B,
那么 A 对内存的修改,对 B 可见。
happens-before 的几种来源:
1. 同一个 goroutine 内的顺序(程序顺序)
2. channel 的发送和接收
3. 锁的解锁和加锁
4. sync/atomic 操作
5. sync.Once、sync.WaitGroup 等
Go 内存模型的核心规则(简化):
1. 程序顺序:同一 goroutine 内,前面的语句 happens-before 后面的
2. 通道:向 channel 发送 happens-before 接收完成
(发送的数据,接收方一定能看到)
3. 锁:解锁 happens-before 后续的加锁
(临界区的修改,下一个加锁者能看到)
4. 原子操作:原子写 happens-before 后续的原子读(同一个变量)
5. 没有任何 happens-before 关系的两个操作,就是"并发的",
它们的顺序未定义,可能看到任何结果
关键:如果两个操作之间没有 happens-before 关系,Go 不保证它们的可见性。
var x int
func reader() {
fmt.Println(x) // 读 x
}
func writer() {
x = 42 // 写 x
}
// 如果 reader 和 writer 并发执行,且没有同步,
// reader 读到什么?未定义!可能是 0,可能是 42
// 这是"数据竞态"(data race)sync.Once 为什么 atomic + mutex?
sync.Once 保证”只执行一次”,它的实现巧妙地结合了 atomic 和 mutex:
// sync.Once 的简化实现
type Once struct {
done atomic.Uint32 // 原子标志
m Mutex
}
func (o *Once) Do(f func()) {
if o.done.Load() == 0 { // (1) 快路径:原子读,看是否已执行
o.doSlow(f) // 可能还没执行,走慢路径
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock() // (2) 加锁,保证只有一个执行
defer o.m.Unlock()
if o.done.Load() == 0 { // (3) 双重检查(double-check)
defer o.done.Store(1) // (4) 原子写,标记已执行
f() // (5) 执行
}
}为什么这样设计?
1. 快路径用 atomic(done.Load()):
绝大多数情况(Once 已执行),一个原子读就返回,零锁开销
2. 慢路径用 mutex:
少数情况(还没执行),加锁保证"只有一个 goroutine 执行 f"
加锁的 acquire/release 语义,保证 f 的修改对其他 goroutine 可见
3. 双重检查(double-check):
加锁后再检查一次 done,因为可能有多个 goroutine 同时进入慢路径,
第一个加锁执行了,后面的要"重新检查"发现已执行,跳过
为什么不能只用 atomic 或只用 mutex?
只用 atomic:
if done.CompareAndSwap(0, 1) { f() }
问题:CAS 成功后,f 还没执行完,其他 goroutine 看到 done=1 就返回了
→ 但 f 可能还没执行完!其他 goroutine 用了"未初始化"的资源
→ 这是错误的(经典的双重检查锁 bug)
只用 mutex:
o.m.Lock(); defer o.m.Unlock()
if !done { done = true; f() }
问题:每次 Do 都要加锁,即使 Once 已经执行了
→ 高频调用时,锁开销大
所以 atomic + mutex 的组合是精髓:
atomic 负责"快路径"(已执行的情况,快速判断)
mutex 负责"慢路径"(未执行的情况,保证互斥 + 可见性)
而 mutex 的 acquire/release 语义,保证了:
f() 执行完后(done.Store 之前,在 unlock 的 release 内),
其他 goroutine 加锁(acquire)后,能看到 f 的所有修改。
sync.Once 是”快路径 + 慢路径”(futex 那套思想)和”acquire/release 可见性”的完美结合。
它展示了一个重要的并发编程模式:双重检查锁(double-checked locking)。
但这个模式非常容易写错(Java 早期版本的双重检查锁就有著名的 bug,因为缺了 volatile)。Go 的 sync.Once 帮你写对了。
教训:复杂的并发模式(双重检查锁),应该用库提供的正确实现,而不是自己手写。 因为”看起来对”和”真的对”之间,隔着内存模型这道鸿沟。
Q11 这一套知识在你的日常开发里怎么用?
最后,把这一章的知识落地到 Go 开发的实践。
实践一:用高级同步原语,不要手写屏障。
// 好:用锁、channel、atomic、sync.Once
// 坏:手写内存屏障(Go 根本不让你直接写屏障,
// 因为 Go 的内存模型用 happens-before 抽象,屏蔽了硬件差异)Go 的设计哲学:你不需要知道 x86 的 mfence 和 ARM 的 dmb,你只需要知道 happens-before 规则。
实践二:用 -race 检测数据竞态。
go test -race ./...
go run -race main.go
# race detector 能检测到"没有 happens-before 关系的并发读写"
# 这是最有效的并发 bug 检测工具实践三:理解”什么操作有 happens-before 关系”。
有 happens-before(安全):
- channel 的 send → receive
- mutex 的 unlock → lock
- atomic 的 store → load
- sync.Once、WaitGroup、Cond
没有 happens-before(竞态):
- 两个 goroutine 直接读写同一个变量(无同步)
- 用普通 bool 标志位做"通知"(应该用 channel 或 atomic)
一个常见的错误模式:
// 错误:用普通变量做"通知"
var done bool
func worker() {
// 做了一些工作
done = true // 写 done
}
func main() {
go worker()
for !done { // 读 done
time.Sleep(time.Millisecond)
}
// done = true 和这里读 done 没有 happens-before 关系
// 这是数据竞态!-race 会报错
}
// 正确:用 channel 或 atomic
var done = make(chan struct{})
func worker() {
// 做了一些工作
close(done) // close 是 happens-before 的
}
func main() {
go worker()
<-done // 等 done 关闭
}实践四:理解”锁保护的不只是互斥,还有可见性”。
var mu sync.Mutex
var data map[string]int
// 写者
mu.Lock()
data = map[string]int{"a": 1} // 修改
mu.Unlock() // release:修改对后续 lock 可见
// 读者
mu.Lock() // acquire:能看到写者的修改
v := data["a"]
mu.Unlock()
// 关键:锁不只是"防止同时访问",还保证"可见性"
// 很多人以为"只要不并发写就安全",忽略了可见性实践五:警惕”看起来原子的多步操作”。
// 错误:以为"读 + 判断 + 写"是原子的
if counter < 100 { // 读
counter++ // 写,但可能中间被别人改
}
// 正确:用 atomic 的 CAS
for {
old := atomic.LoadInt64(&counter)
if old >= 100 {
break
}
if atomic.CompareAndSwapInt64(&counter, old, old+1) {
break
}
}实践六:伪共享的排查。
// 如果你发现"两个 goroutine 各写各的字段,但性能很差",
// 怀疑伪共享,用 padding 分隔字段
type Stat struct {
requests int64
_ [56]byte // padding
errors int64
_ [56]byte
latency int64
}这一章的最终落脚点:
内存模型的复杂性,是”性能优化”和”并发正确性”冲突的产物。
编译器和 CPU 为了性能,会重排、缓存、缓冲。这些优化在单线程下无懈可击,但在多线程下暴露。
而语言(Go/Java/C++)的内存模型,是为了让你”在不理解具体硬件的前提下,写出正确的并发代码”。
所以:遵守语言内存模型(happens-before),用高级同步原语(锁、channel、atomic),用 -race 检测,别手写屏障。 这就是你在工程里需要做的全部。
思考题
思考题 1
面试官问:“什么是内存可见性?为什么需要 volatile(或 Go 里的 atomic)?“请你用”编译器重排 + CPU 乱序 + store buffer”三个层次回答,并解释 volatile 和 atomic 的区别。
参考答案
核心答案:内存可见性 = 一个线程对内存的修改,另一个线程能否”及时、正确”地看到。之所以会有可见性问题,是因为编译器、CPU、缓存三层都可能”延迟”或”重排”这个修改。
第一层:编译器重排。
// 编译器可能把"写 done"重排到"写 data"之前
// (因为编译器看不出 data 和 done 在另一个线程里的关系)
data = compute(); // 写数据
done = true; // 写标志
// 编译器可能优化成:
done = true; // 先写标志
data = compute(); // 后写数据
// 另一个线程看到 done=true,但 data 还没写好 → 读到错误数据第二层:CPU 乱序执行。
CPU 的乱序执行 + 顺序提交,保证"单线程"的顺序,
但"其他 CPU 通过缓存观察到的顺序"可能不同。
线程 1 的"写 data"和"写 done",可能以相反的顺序被线程 2 观察到。
第三层:store buffer(写缓冲)。
CPU 执行"写 done = true"时,这个写先进入 store buffer,
暂时没刷新到缓存/内存。
其他 CPU 此时读 done,读到的是旧值 false,
因为 store buffer 里的写还没"可见"。
为什么需要 volatile / atomic?
它们的作用是在这些层次上施加”顺序”和”可见性”约束:
volatile(C/Java):
1. 禁止编译器对 volatile 变量的重排
2. 读写 volatile 变量时,插入必要的内存屏障
3. 保证 volatile 写对其他线程"可见"
Go 的 atomic:
1. atomic 操作是原子的(不可分割)
2. atomic 操作隐含内存屏障(acquire/release 语义)
3. 保证 happens-before 关系
volatile 和 atomic 的区别(这是关键):
volatile(Java/C):
- 保证"可见性"和"顺序",但不保证"原子性"
- volatile int i; i++ 不是原子的!
(i++ 是"读-改-写",volatile 只保证每一步可见,不保证整体原子)
- 所以 volatile 不能用来实现计数器(会丢更新)
atomic(Go/Java 的 AtomicInteger):
- 保证"原子性" + "可见性" + "顺序"
- atomic.AddInt64 是原子的"读-改-写"
- 可以用来实现计数器、CAS 等
一句话:
volatile = 可见性(visibility)
atomic = 原子性 + 可见性(atomicity + visibility)
Java 里 volatile 和 AtomicInteger 的对比:
// volatile:可见性,但 i++ 不原子
volatile int i = 0;
i++; // 不是原子的!多线程会丢更新
// AtomicInteger:原子 + 可见
AtomicInteger ai = new AtomicInteger(0);
ai.incrementAndGet(); // 原子自增,安全一个经典的错误:以为 volatile 能解决计数问题。
volatile int count = 0;
// 10 个线程各加 100 万次
// count 最终不是 1000 万!因为 count++ 不是原子的
// volatile 只保证"读到的都是最新的",不保证"读-改-写"不被打断Go 里的对应:
Go 没有 volatile 关键字,对应的是 sync/atomic:
// Go 的 atomic 同时提供了原子性和可见性
var count int64
atomic.AddInt64(&count, 1) // 原子自增
// 而普通变量(非 atomic)连可见性都没有
var flag bool // 两个 goroutine 读写,没有 happens-before,是竞态这道题考察什么:
- 能不能从”编译器、CPU、缓存”三层说清可见性问题的根源
- 知不知道 volatile 保证什么(可见性)不保证什么(原子性)
- 知不知道 atomic 和 volatile 的区别
- 能不能举出”volatile 不能解决计数”这个反例
思考题 2
你的服务要维护一个”只增不减”的配置列表(比如黑名单),多个 goroutine 会读这个列表,偶尔会有 goroutine 添加新条目。你用一个 []string 切片 + atomic.Pointer 来存它:
var blacklist atomic.Pointer[[]string]
func read() []string {
return *blacklist.Load()
}
func add(item string) {
old := *blacklist.Load()
newList := append(old, item) // 问题?
blacklist.Store(&newList)
}这段代码有什么问题?请指出并修复,并说明”不可变 + 原子替换”相比”加锁”的优劣。
参考答案
这段代码的问题:并发 add 会丢失更新。
func add(item string) {
old := *blacklist.Load() // (1) 读旧列表
newList := append(old, item) // (2) 基于旧列表,append 新条目
blacklist.Store(&newList) // (3) 存新列表
}
// 问题:两个 goroutine 并发 add:
// goroutine A:读到 old=[x],准备 append y → [x,y]
// goroutine B:读到 old=[x],准备 append z → [x,z]
// A Store [x,y],B Store [x,z]
// → 最终是 [x,z],丢失了 y!
// (A 的更新被 B 覆盖了)根本问题:append(old, item) 是”读-改-写”,不是原子的。
atomic.Pointer 保证的是”读和写是原子的、可见的”,但它不保证”读-改-写”这个复合操作的原子性。
修复方案一:加锁(简单正确)。
var mu sync.Mutex
var blacklist []string
func read() []string {
mu.Lock()
defer mu.Unlock()
return blacklist
}
func add(item string) {
mu.Lock()
defer mu.Unlock()
blacklist = append(blacklist, item)
}修复方案二:CAS 循环(无锁,但复杂)。
var blacklist atomic.Pointer[[]string]
func add(item string) {
for {
old := blacklist.Load()
newList := make([]string, len(*old)+1)
copy(newList, *old)
newList[len(*old)] = item
// CAS:只有 blacklist 还是 old 时,才替换成 newList
if blacklist.CompareAndSwap(old, &newList) {
return // 成功
}
// CAS 失败(被别的 goroutine 改了),重试
}
}关键区别:
方案一(加锁):
读也加锁、写也加锁,串行化
→ 简单,但读也被锁住
方案二(atomic + CAS):
读完全无锁(Load 就是原子读指针)
写用 CAS 重试(冲突时重试)
→ 读快,写冲突时可能重试多次
修复方案三:用 channel 串行化写(更符合 Go 哲学)。
var blacklist atomic.Pointer[[]string]
// 专门的 goroutine 管理更新
var updates = make(chan string, 10)
func init() {
go func() {
for item := range updates {
old := *blacklist.Load()
newList := append(old, item)
blacklist.Store(&newList)
}
}()
}
func add(item string) {
updates <- item // 串行化更新
}
func read() []string {
return *blacklist.Load() // 读无锁
}“不可变 + 原子替换” vs “加锁”的优劣:
不可变 + 原子替换(atomic.Pointer):
优点:
1. 读完全无锁(读就是 Load 一个指针,~1ns)
2. 读不阻塞写、写不阻塞读
3. 读到的永远是一个"一致快照"(因为切换是原子的)
缺点:
1. 写要"复制 + 替换"(如果列表大,复制开销大)
2. 并发写要用 CAS 循环或串行化,否则丢更新
3. 只适合"读多写少"
加锁(Mutex):
优点:
1. 简单,不会写错
2. 读写的正确性容易保证
缺点:
1. 读也被锁住(读多时是瓶颈)
2. 锁的获取有开销
这道题的判断标准:
读多写少 → 不可变 + 原子替换(读无锁)
读写都频繁 → 加锁(或者分片)
写多 → 重新考虑设计(是不是该用别的结构)
一个关键的理解(这道题的核心):
“不可变”和”原子替换”是绝配:
- 不可变:数据一旦创建就不变,所以”读”永远安全(不用锁)
- 原子替换:更新时生成新数据,原子地换指针
这两个配合,就实现了”读无锁、写安全”。
这就是 RCU 思想在 Go 里的体现(第 5 章讲过)。而 atomic.Pointer 就是”原子替换”的工具。
但注意:不可变 + 原子替换,解决的是”读”的并发问题,“写”的并发(两个 goroutine 同时更新)还是要处理(CAS 或加锁或串行化)。
思考题 3
伪共享问题:你的服务有一个统计结构,记录”请求数、错误数、总延迟”三个计数器,每个被不同的 goroutine 高频更新。当前实现是三个 int64 字段紧挨着。你会怎么优化?padding 到缓存行对齐是万能的吗?
参考答案
问题分析:
type Stats struct {
requests int64 // goroutine 1 高频更新
errors int64 // goroutine 2 高频更新
latency int64 // goroutine 3 高频更新
}
// 三个 int64 各 8 字节,共 24 字节,在同一个 64 字节缓存行里
// goroutine 1 写 requests,会让 goroutine 2 的 errors、goroutine 3 的 latency
// 所在的缓存行失效(MESI 协议,以缓存行为单位失效)
// → 三个 goroutine 疯狂互相失效缓存行 → 伪共享 → 性能暴跌优化方案:padding 到缓存行对齐。
const cacheLineSize = 64
type Stats struct {
requests int64
_ [cacheLineSize - 8]byte // padding,让 requests 独占一个缓存行
errors int64
_ [cacheLineSize - 8]byte
latency int64
_ [cacheLineSize - 8]byte
}
// 这样 requests、errors、latency 各占一个缓存行,
// 不同 goroutine 写它们不会互相失效Go 1.20+ 有更优雅的方式(但不是标准库):
// 用结构体对齐,而不是手动 padding
type PaddedInt64 struct {
val int64
_ [7]int64 // 填充到 64 字节
}
type Stats struct {
requests PaddedInt64
errors PaddedInt64
latency PaddedInt64
}但 padding 不是万能的,有几个问题:
问题一:内存浪费。
原来:3 个 int64 = 24 字节
padding 后:3 个 64 字节 = 192 字节
内存占用 8 倍
如果 Stats 有很多实例(比如每个连接一个),内存浪费巨大
问题二:缓存行大小不是固定的。
x86:64 字节
ARM:64 字节(大部分)
但有些架构(比如 Apple M1 是 128 字节?实际是 64)
PowerPC:128 字节
如果假设 64 字节,在 128 字节缓存行的架构上,padding 不够
问题三:Go 的内存分配器可能重排字段。
Go 会做”字段对齐优化”,可能把字段重排(虽然同类型的字段一般保持顺序)。padding 依赖于”字段顺序”和”对齐”,这是脆弱的。
问题四:不是所有情况都需要 padding。
只有当"多个 goroutine 高频写相邻字段"时才需要。
如果写频率低,伪共享的影响小,padding 反而浪费内存。
更好的替代方案:
方案一:每个 goroutine 用自己的 Stats,最后合并。
// 每个 goroutine 独立的 Stats,无共享,无伪共享
// 最后用 atomic 合并到全局
// 这是"分片"思想的体现(第 5 章)方案二:用 atomic 变量分开声明。
var requests atomic.Int64
var errors atomic.Int64
var latency atomic.Int64
// 分开声明,编译器可能把它们放在不同缓存行(不保证)
// 但 atomic 操作本身会处理一致性方案三:用 sync.Pool 或 per-goroutine 本地统计。
// 每个 goroutine 维护本地统计,定期 flush 到全局
// 类似 Go runtime 的 mstats(每个 P 有本地统计)判断是否真的需要优化:
1. 先用 perf 或 Go 的 pprof 确认"伪共享是瓶颈"
- 别"预防性优化"(premature optimization)
2. 确认"多个 goroutine 高频写相邻字段"确实发生
- 如果只是"读多写少",伪共享影响小
3. 考虑"减少共享"而不是"padding"
- 分片、per-goroutine 统计,从根上消除伪共享
- padding 是"治标",分片是"治本"
一个真实的例子(Go runtime 的 mstats):
Go 的 runtime 里,mstats 结构体确实有 padding 来避免伪共享,但那是”极致性能场景”(runtime 自己)。普通业务代码,先考虑”分片/减少共享”,padding 是最后手段。
这道题的核心:
伪共享的根源是”物理上共享缓存行”,而解决思路有两层:
- 治标:padding,让字段物理上分开(但浪费内存、脆弱)
- 治本:减少共享,让字段”逻辑上”分开(分片、per-goroutine)
而最重要的教训是:先测量,确认伪共享是瓶颈,再优化。 不要因为”听说过伪共享”就到处 padding。
思考题 4(开放题)
“内存模型”这个概念,在单机多线程(Go happens-before)和分布式系统(比如数据库的一致性模型)之间,有什么对应关系?你能从”内存一致性模型”推导出对”分布式一致性模型”的理解吗?
参考答案
这是一道”知识迁移”的开放题,考察你能不能把”单机的内存模型”抽象成”通用的并发一致性”。
核心对应关系:
单机多线程的内存模型,和分布式系统的一致性模型,
本质是同一个问题在不同尺度上的体现:
问题:多个"执行单元"(线程/节点)操作"共享状态"(内存/数据),
如何定义"谁看到了什么、以什么顺序看到"?
单机:执行单元 = 线程,共享状态 = 内存,通信 = 读/写内存
分布式:执行单元 = 节点/进程,共享状态 = 数据(DB/KV),通信 = 消息
具体的对应:
| 概念 | 单机(内存模型) | 分布式(一致性模型) |
|---|---|---|
| 强一致 | 顺序一致性(SC) | 线性一致性(Linearizability) |
| 弱一致 | 弱内存模型(x86 TSO、ARM) | 最终一致性(Eventual) |
| 同步原语 | 锁、屏障、atomic | 事务、共识协议、锁 |
| happens-before | 内存模型的 happens-before | 因果一致性(Causal) |
| 可见性 | 一个线程的写何时对其他线程可见 | 一个节点的写何时对其他节点可见 |
几个关键概念的对应:
1. 顺序一致性 ↔ 线性一致性。
顺序一致性(内存模型的最强保证):
所有线程的读写,存在一个"全局的顺序",和程序顺序一致,
所有线程看到的都是这个全局顺序
线性一致性(分布式的最强保证):
所有操作,存在一个"全局的顺序",和操作的真实时间顺序一致,
所有客户端看到的都是这个全局顺序
本质:都要求"存在一个全局一致的顺序"
2. happens-before ↔ 因果一致性。
happens-before(内存模型):
如果 A happens-before B,则 A 的写对 B 可见
并发操作(无 happens-before)的顺序未定义
因果一致性(分布式):
如果操作 A 因果先于操作 B,则所有节点按 A→B 的顺序看到它们
并发操作(无因果)的顺序可以不同
本质:都只保证"有因果关系的操作有序",不要求"并发操作有序"
3. store buffer 的延迟 ↔ 主从复制的延迟。
store buffer:CPU 的写暂时不可见(延迟)
主从复制:主库的写暂时没同步到从库(延迟)
两者都导致"写后读"读到旧值:
单机:写 x=1,马上读 x(可能从另一个 CPU 读,读到 0)
分布式:写主库,马上读从库(从库还没同步,读到旧值)
对应的解决方案也是类似的:
单机:内存屏障(强制可见)
分布式:读写都走主库(read-your-writes),或强制读主
4. 锁 ↔ 分布式锁 + 共识。
单机锁:保证临界区互斥,unlock 的 release 让下一个 lock 看到修改
分布式锁:保证跨节点的互斥,释放锁让下一个持有者看到修改(第 17 章)
共识协议(Raft/Paxos):保证多个副本的状态一致(第 18 章)
从内存模型推导分布式一致性的理解:
推导一:一致性的强度,就是”顺序保证”的强度。
单机:从"顺序一致"(最强)到"弱内存模型"(最弱),是一系列"允许哪些重排"的选择
分布式:从"线性一致"(最强)到"最终一致"(最弱),是一系列"允许哪些延迟/乱序"的选择
两者都是"为了性能,放宽一致性的强度",
而放宽多少,取决于"你的业务能不能容忍"。
推导二:可见性延迟,是性能优化的代价。
单机:store buffer 让写"延迟可见",换来 CPU 不用等写完成
分布式:异步复制让写"延迟可见",换来写入不用等所有副本确认
两者的权衡是同一个:
要"强一致"(及时可见)→ 慢
要"快"(延迟可见)→ 弱一致
推导三:解决”可见性”问题的手段,也是对应的。
单机:内存屏障(强制可见)、锁(acquire/release)
分布式:同步复制(强制可见)、事务/共识(保证顺序)
本质都是"在需要一致性的时候,付出同步的代价"
一个更深的洞察:
“一致性”的本质,是”顺序”和”可见性”的组合。
- 顺序(order):多个操作以什么顺序被观察
- 可见性(visibility):一个操作的结果何时被看到
单机内存模型和分布式一致性模型,都是在”顺序”和”可见性”这两个维度上,定义不同的”保证等级”。
理解了这一点,你就发现:
- Go 的 happens-before、Java 的 JMM、C++ 的 memory order,和
- 数据库的隔离级别、分布式的一致性模型(线性一致、因果一致、最终一致),
其实是同一门学问:并发系统的正确性规格说明书。
这道题的价值:
它让你意识到,“并发”和”分布式”不是两门课,是一门课的两个尺度。
单机多线程的问题(竞态、可见性、死锁、一致性), 在分布式系统里都会以”更大尺度”再次出现。
所以:
- 理解了内存模型,你就理解了分布式一致性的”思想原型”
- 理解了分布式一致性,你就能反过来深化对内存模型的理解
这就是为什么这本书第 17、18 章讲分布式锁和共识时,你会发现它们和前面的锁、内存模型”长得这么像”——因为它们本来就是同一个问题的不同尺度。
一页纸总结
┌──────────────────────────────────────────────────────────────┐
│ 第 07 章 · 内存模型与屏障:编译器和 CPU 都在骗你 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【核心真相】 │
│ 代码顺序 ≠ 执行顺序(编译器重排 + CPU 乱序 + store buffer) │
│ 单线程无感知(顺序提交保证),多线程暴露(可见性问题) │
│ │
│ 【三层"欺骗"】 │
│ 编译器重排:在"不改变单线程语义"前提下调整顺序 │
│ CPU 乱序:乱序执行 + 顺序提交,多核观察结果乱 │
│ store buffer:写延迟刷新,其他 CPU 暂时读不到 │
│ │
│ 【MESI 缓存一致性】 │
│ 保证"单个地址"最终一致(缓存一致性) │
│ 不保证"多个操作"的顺序(内存一致性) │
│ store buffer + invalidate queue = 可见性延迟 │
│ │
│ 【内存屏障】 │
│ StoreStore / LoadLoad / StoreLoad / LoadStore │
│ 屏障 = 给重排"划界",墙两边不能跨越 │
│ acquire/release = 屏障的语义化(锁的根基) │
│ │
│ 【x86 vs ARM】 │
│ x86(TSO):强,自动保证大部分顺序,只有 StoreLoad 会重排 │
│ ARM:弱,几乎全允许重排,要显式屏障 │
│ → x86 测不出 bug ≠ 代码正确,ARM 上可能炸 │
│ │
│ 【Go 内存模型】 │
│ happens-before:channel、锁、atomic、Once 提供顺序保证 │
│ 无 happens-before = 并发 = 竞态 │
│ sync.Once = atomic 快路径 + mutex 慢路径(双重检查锁) │
│ │
│ 【工程实践】 │
│ 用高级原语(锁/channel/atomic),别手写屏障 │
│ 用 -race 检测竞态 │
│ 锁不只保证互斥,还保证可见性(acquire/release) │
│ 警惕伪共享(padding 治标,分片治本) │
│ │
│ 【一句话】 │
│ 内存模型 = 并发系统的正确性规格书; │
│ 单机内存模型和分布式一致性,是同一门学问的两个尺度。 │
│ │
│ 【下一章预告】 │
│ 世界不是顺序执行的——中断、软中断、事件循环, │
│ 下一章讲操作系统如何应对"异步"这个根本现实。 │
└──────────────────────────────────────────────────────────────┘