硬件的真相:多核、缓存行与指令重排
接下来五章会很不舒服,因为它们要拆掉你的两个基本假设:「我读的是内存」和「代码是按我写的顺序执行的」。这两句话在单线程世界里是真的 —— 而 CPU 花了三十年的功夫,就是为了让你在单线程里察觉不到它们是假的。多线程掀开了这块布。所有的并发 bug —— 竞态、可见性、那个诡异的「加了 print 就好了」—— 全都源自这一层。
第一个谎言:你以为你在读内存
先看一组数字。把 CPU 的一个时钟周期(约 0.3 纳秒)想象成 1 秒,然后按比例放大:
| 操作 | 真实耗时 | 放大成人类尺度 |
|---|---|---|
| 一个 CPU 周期 | ~0.3 ns | 1 秒 |
| 读 L1 缓存 | ~1 ns | 3 秒 —— 手边的东西 |
| 读 L2 缓存 | ~4 ns | 13 秒 —— 抽屉里 |
| 读 L3 缓存 | ~15 ns | 50 秒 —— 走去隔壁房间 |
| 读 主内存 | ~80–100 ns | 5 分钟 —— 开车去仓库 |
| 一次上下文切换 | ~1–5 µs | 1–4 小时 |
| 读一次 NVMe SSD | ~50–100 µs | 2–4 天 |
| 同机房网络往返 | ~0.5 ms | 19 天 |
| 跨大陆网络往返 | ~150 ms | 16 年 |
看最后两行。网络比 L1 缓存慢一亿倍。
这就是为什么「并发」值得花这么大力气:一个 Web 请求里,CPU 真正在算的时间可能只有 1%,剩下 99% 都是在用人类的耐心等仓库。
也是为什么「读内存」比你以为的贵:主内存比 L1 慢 100 倍。所以 CPU 有一整套精密的机器(缓存、预取、乱序执行)来尽量不去碰内存 —— 而那套机器,正是并发 bug 的温床。
缓存行:64 字节的最小单位
关键事实:CPU 从不按字节读内存。它按「缓存行」读,一行 64 字节。
你读一个 int(4 字节),CPU 会把它周围的 64 字节一起搬进 L1。这个设计基于「空间局部性」假设 —— 你读了 arr[0],多半马上要读 arr[1]。
这个设计在单线程里是纯赚。在多线程里,它会咬人。
伪共享:两个变量,一场灾难
// 两个计数器,两个线程各管一个。它们之间毫无关系。
class Counters {
@Volatile var a = 0L // 8 字节
@Volatile var b = 0L // 8 字节,紧挨着 a
}
// 线程 1: 疯狂 counters.a++
// 线程 2: 疯狂 counters.b++
//
// 它们没有共享任何数据。应该是完美并行的。
// 实测:比单线程还慢 2~10 倍。
为什么?因为 a 和 b 挨在一起,它们在同一个 64 字节的缓存行里。
而缓存一致性协议(MESI)的工作单位也是缓存行:
a,MESI 要求它独占这个缓存行 → 让核 2 的副本失效。核 2 要改
b,也要独占同一个缓存行 → 让核 1 的副本失效。两个核在为一个它们根本不共享的缓存行来回抢,每次都要走 L3 / 内存总线。
它们没有共享数据,但共享了一条缓存行。这就是伪共享(false sharing)。
怎么修:把它们隔开
// 方法一:填充(padding)—— 老派但通用
class PaddedCounter {
@Volatile var value = 0L
// 填 7 个 long = 56 字节,把下一个变量挤到另一条缓存行去
@Volatile var p1 = 0L; @Volatile var p2 = 0L; @Volatile var p3 = 0L
@Volatile var p4 = 0L; @Volatile var p5 = 0L; @Volatile var p6 = 0L
@Volatile var p7 = 0L
}
// 方法二:JVM 提供的官方姿势
@jdk.internal.vm.annotation.Contended // JDK 8+,需要 -XX:-RestrictContended
class Counter { @Volatile var value = 0L }
// 方法三(最实用):根本不要共享。
// 每个线程一个自己的计数器,最后 sum 一次。
// ↑ 这就是 Java 的 LongAdder 和 Go 的 sync/atomic 分片计数器在做的事。
- Java:这是
LongAdder存在的全部理由。AtomicLong在高竞争下就是一条被疯狂争抢的缓存行;LongAdder把它拆成一个数组,每个线程打自己那一格(而且格子之间用@Contended隔开)。高竞争下 LongAdder 能快十倍。 - Go:典型现场是
[]int64这种「每个 worker 一个下标」的统计数组。把它改成[]struct{ v int64; _ [56]byte }就好了。Go runtime 自己内部到处在做这个(搜pad字段)。 - Python:基本不用担心 —— 你的性能瓶颈在解释器上,差了两个数量级,伪共享的开销淹没在噪音里。(但如果你在写 C 扩展,就要担心了。)
什么时候需要管它?只有当你在写高频的、多核并发的计数器/统计/无锁队列时。业务代码里,99% 的情况你不需要想这件事。但当你看到「加了个核反而更慢」这种诡异现象时 —— 想起这一节。
第二个谎言:你以为代码按顺序执行
看这段代码。x 和 y 初始都是 0。
// 线程 1 // 线程 2 x = 1 y = 1 r1 = y r2 = x // 问:r1 == 0 && r2 == 0 有可能吗?
凭直觉:不可能。因为不管两个线程怎么交错,总有一个线程的写会先发生,另一个线程就该读到它。
但在真实的 x86 CPU 上,r1 == 0 && r2 == 0 是会发生的。(在 ARM 上更容易。)
凶手一:CPU 的写缓冲区(Store Buffer)
回到那张表:写内存要 100ns。如果每次 x = 1 都真的等着写进内存(甚至等着通知其他核让它们的缓存失效),CPU 会闲死。
所以 CPU 加了一个写缓冲区:x = 1 先扔进一个小队列,CPU 立刻接着执行下一条指令,写缓冲区在后台慢慢刷。
后果:线程 1 的 x = 1 还躺在核 1 的写缓冲区里(别的核看不见),而 r1 = y 已经执行了 —— 读到了旧的 y = 0。线程 2 那边同时发生同样的事。
于是 r1 == 0 && r2 == 0。这个重排叫 StoreLoad 重排,而它是 x86 上唯一允许的重排(x86 的内存模型已经算是很强的了 —— ARM 和 RISC-V 允许的重排多得多,所以并发 bug 在 ARM 上更容易复现)。
凶手二:编译器
编译器也在重排,而且它的理由和 CPU 一样:「反正单线程看不出来」。
// 你写的
var flag = false
var data = 0
// 线程 1:
data = 42 // (1)
flag = true // (2)
// 线程 2:
if (flag) {
print(data) // 期望打印 42
}
// 但编译器/CPU 完全可以把 (1) 和 (2) 换个顺序执行 ——
// 因为在「单线程」的视角里,它们互不依赖,换了也没区别。
//
// 于是线程 2 可能看到 flag == true, 但 data 还是 0。
这就是「可见性」和「有序性」问题的根。而下一章会把它和「原子性」一起拆开。
内存屏障:唯一的解药
解药只有一个:告诉编译器和 CPU 「这里不许重排」。这个指令叫内存屏障(memory barrier / fence)。
但你几乎永远不需要手写它。语言层面已经给你包好了:
| 你写的 | 底下插了什么 | 保证了什么 |
|---|---|---|
volatile(Java/Kotlin) | 写之后插 StoreStore + StoreLoad 读之前插 LoadLoad + LoadStore | 可见性(写了别人立刻看得见)+ 有序性(禁止跨越它重排)。 但不保证原子性 —— volatile counter++ 照样会丢更新! |
synchronized / Mutex | 进入时 acquire 屏障,退出时 release 屏障 | 三样全给:原子性 + 可见性 + 有序性。这就是为什么「用锁」是最省心的方案。 |
Go 的 sync/atomic | 对应的原子指令 + 屏障 | Go 内存模型明确:atomic 操作提供 sequentially consistent 语义 |
| Go 的 channel 收发 | 内部有锁和屏障 | 建立 happens-before 关系 —— 这是 Go 内存模型的核心保证(第 17 章) |
| Python 的一切 | GIL 本身就是一把大锁 | GIL 在切换时会做完整的同步。这是 Python 程序员从没被内存模型咬过的原因 —— 而 free-threaded Python 会把这层保护拿掉(第 12 章)。 |
你不需要记住 MESI、不需要记住 StoreLoad 屏障。你需要记住的只有一个概念:happens-before。
如果操作 A happens-before 操作 B,那么 A 的所有结果,B 一定看得见。
而 happens-before 关系只能靠同步原语建立:
- 解锁 happens-before 后续对同一把锁的加锁
- volatile 写 happens-before 后续对同一变量的 volatile 读
- channel 发送 happens-before 对应的接收完成(Go)
- 线程启动 happens-before 该线程里的任何操作
- 协程挂起前的操作 happens-before 恢复后的操作
没有建立 happens-before 关系的两个操作,编译器和 CPU 可以随便重排、随便让你看不见。这不是 bug,这是规范允许的。
所以:不要试图靠「猜执行顺序」写并发代码。要靠同步原语明确地建立顺序。
这是并发 bug 最经典的症状之一,而且它不是幻觉,是有物理成因的:
println内部有锁(标准输出是共享资源)→ 无意中插入了一个内存屏障 → 可见性问题被掩盖了。println很慢 → 改变了线程的时序 → 那个只有几纳秒的竞态窗口错过了。- Debugger 里单步执行 → 时序完全变了 → bug 消失。这就是「海森堡 bug」(Heisenbug)—— 观测行为本身改变了结果。
所以:如果加个日志 bug 就没了,不要庆祝 —— 那几乎必然是一个真实存在的并发 bug,而你刚刚把它藏起来了。它会在你没有日志的那个生产环境里,在流量最高的那个凌晨,回来找你。
正确的工具:竞态检测器。Go 的 go test -race、Java 的 JCStress、C/C++ 的 ThreadSanitizer、Python 3.14 free-threaded 的 TSan 构建。它们不靠运气 —— 它们检测的是「有没有建立 happens-before 关系」。
1. 写一个伪共享的基准测试:两个线程各自 ++ 一个相邻的 long,跑 1 亿次;然后给它们中间填 56 字节,再跑一次。差距会让你记住这一章。(JMH 或者简单的 System.nanoTime() 都行。)
2. 在你的 Go 项目上跑一次 go test -race ./...。如果它报出了东西 —— 那不是误报,那是一个正在等待复现的生产事故。
3. 找一个你写过的 volatile。问自己:我用它是为了「可见性」还是为了「原子性」?如果是后者 —— 那是错的,volatile 从来不保证原子性(下一章会证明)。
下一章:线程本身。它凭什么这么贵?—— 一个 8MB 的承诺,和一笔 1 微秒的税。