幕 II · 机器CH 06进度 06/25

硬件的真相:多核、缓存行与指令重排

接下来五章会很不舒服,因为它们要拆掉你的两个基本假设:「我读的是内存」「代码是按我写的顺序执行的」。这两句话在单线程世界里是真的 —— 而 CPU 花了三十年的功夫,就是为了让你在单线程里察觉不到它们是假的。多线程掀开了这块布。所有的并发 bug —— 竞态、可见性、那个诡异的「加了 print 就好了」—— 全都源自这一层。

缓存层级MESI写缓冲区伪共享内存屏障

第一个谎言:你以为你在读内存

先看一组数字。把 CPU 的一个时钟周期(约 0.3 纳秒)想象成 1 秒,然后按比例放大:

操作真实耗时放大成人类尺度
一个 CPU 周期~0.3 ns1 秒
L1 缓存~1 ns3 秒 —— 手边的东西
L2 缓存~4 ns13 秒 —— 抽屉里
L3 缓存~15 ns50 秒 —— 走去隔壁房间
主内存~80–100 ns5 分钟 —— 开车去仓库
一次上下文切换~1–5 µs1–4 小时
读一次 NVMe SSD~50–100 µs2–4 天
同机房网络往返~0.5 ms19 天
跨大陆网络往返~150 ms16 年
◆ 定论 · 这张表是整本书的经济学基础

看最后两行。网络比 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 倍。

为什么?因为 ab 挨在一起,它们在同一个 64 字节的缓存行里

而缓存一致性协议(MESI)的工作单位也是缓存行

伪共享:一场没有必要的乒乓球核 1 改 a,核 2 改 b
核 1 (改 a) 改 a 等缓存行 改 a 等缓存行 改 a 改 a
核 2 (改 b) 改 b 等缓存行 改 b 改 b 改 b
发生了什么 核 1 要改 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% 的情况你不需要想这件事。但当你看到「加了个核反而更慢」这种诡异现象时 —— 想起这一节。

第二个谎言:你以为代码按顺序执行

看这段代码。xy 初始都是 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)

⚙ 机器 · 为什么 CPU 要有写缓冲区

回到那张表:写内存要 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 章)。
◆ 定论 · happens-before:唯一你真正需要记住的概念

你不需要记住 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,这是规范允许的。

所以:不要试图靠「猜执行顺序」写并发代码。要靠同步原语明确地建立顺序。

⚠ 陷阱 · 「我加了个 println 就好了」

这是并发 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 微秒的税。