缓存一致性 入门

我们的服务器有 32 颗核在处理进来的 HTTP 请求。每颗核都有自己私有的 L1(~32 KB)和 L2(~1 MB);只有 L3 和 DRAM 是共享的。 当两颗核同时碰连接池的引用计数器,凭啥它们看不到对方的过期值 —— 以及为啥这个「凭啥」是两颗核之间能干的最贵的事?5 个 section 把答案搭出来:合同(coherence vs consistency,几乎人人混在一起的那一段);MESI + 可交互状态走读;代价 —— 弹跳、原子操作、 内存模型;伪共享 + 吞吐悬崖 demo;最后一个速查表,6 道核心冷题 + 每道的答题骨架。

01

合同 —— coherence vs consistency

单核简单。一旦上到两核,软件就得到一个被分成两个不同保证的问题,而几乎所有人都把这俩混在一起。 把它们分清楚,是本篇里最值得先钉死的一点。

单核下「内存」很简单:写地址 X、读 X、拿回刚写的。多核让这个承诺难守得多 —— 硬件给出的答案分成两个分层保证,而几乎所有工程师(包括很多 senior)都把它们搅在一起。 把它们分开,是本篇 primer 里最值得深入理解的一段 —— 也是生产环境出并发 bug 时,你第一个要拿出来用的概念。

场景。现代服务器 CPU 可能有 32 颗核。每颗核都有私有的 L1(~32 KB) 和私有的 L2(~1 MB);只有 L3(32–64 MB)和 DRAM 是共享的。 核 A 读地址 X 时,包含 X 的那条 cache line 被装到 A 的私有 L1。 几个 cycle 后核 B 也读 X,B 的 L1 也装进去 —— 现在, 同一个逻辑内存位置在物理上存在 两份拷贝。如果两边都写 X,任一核接下来的读应该返回什么?

硬件用两个不同的保证回答这个问题:

  • Coherence 是单地址级别的性质。口语化讲:「任一内存位置,从所有核看过去, 就像一条 FIFO 写入队列」。形式化讲:(1)对 X 的每一次写,最终都会被每一核看到; (2)所有核看到对 X 的写,顺序相同。Coherence 就是下一节 MESI 实现的东西。 没它,x = 1; print(x) 同一颗核跑都可能出错。
  • Consistency 是跨地址级别的性质。它管的是对 不同 地址的写, 被观察到的顺序。经典例子是 producer / consumer:
// Producer(核 A):           // Consumer(核 B):
data = 42;                    while (!flag) {}
flag = true;                  print(data);

Coherence 保证核 B 最终会看到 flag = true。 Coherence 不保证 B 是在看到 data = 42 之前、之后、还是穿插着看到 flag = true。这就是 consistency 的问题 —— 在弱内存模型硬件上(ARM、POWER、RISC-V),没有显式内存屏障(fence), 核 B 完全合法地能 print 出 0

为啥总混。两者都是 cache 层级的性质,都由硬件强制。 差别在「保证什么」:coherence 几乎免费、彻底 —— 写代码时你从不显式 reasoning 它。 Consistency 才是 Java 的 volatile、C++ 的 std::atomic memory_order、 Rust 的 Ordering::Acquire/Release、汇编里的 mfence / dmb ish 真正配置的东西。

要点。「缓存一致性(coherence)保证你永远不会读到 单个地址的过期值。内存一致性(consistency)保证 —— 在你不主动告诉硬件你 care 的前提下 ——不同地址之间的值的相对顺序无法依赖。」下一节要看的协议(MESI)就是 coherence 机器;语言标准里的 memory ordering 是 consistency 旋钮。

教科书没说的一点。哪怕 coherence 完美,也不存在一个全局、瞬时、「真正的」 某个地址的值 —— 只有「每颗核缓存里的、当前正在总线上传输的、最终落到 DRAM 里的」这几样。 协议的工作是确保可观察的值序列等价于某个全局 FIFO 顺序。 任意一个 picosecond 的物理状态,远比这个抽象凌乱;软件被刻意禁止去看它。

02

MESI —— 四个状态、一个协议

每个缓存里的每条 cache line 都带 2 比特标签。整套协议就是「核读、核写、snoop 别人时, 这 4 个标签之间怎么迁移」的规则。

Coherence 就是 MESI 实现的东西。协议名字来自每条 cache line 可能处于的 4 个状态:Modified、Exclusive、Shared、Invalid。整张状态表小到可以背 —— 也值得背, 因为生产里所有跟一致性相关的问题,答案都藏在这张表里的某个角落。

状态  别处有副本?    跟 DRAM 一致?    本核能…
─────────────────────────────────────────────────────────
  M   没有            不,DRAM 落后    随便读、随便写
  E   没有            是               随便读、随便写
                                       (写后变 M,静默)
  S   有(其他核)     是               随便读;
                                       要写必须先广播
  I   行空了或过期    —                下次访问必 miss

状态迁移由两类事件驱动。本地处理器事件:PrRd(本核读)、PrWr(本核写);总线 / snoop 事件(从别的核来):BusRd(别人在读)、BusRdX(别人要独占)、 BusUpgr(某个 sharer 想从 S 升 M)、BusWb(写回 DRAM)。 每个缓存一直都在 snoop 总线,哪怕在干自己的活;协议的正确性靠的就是这一点。

完整的状态转移表塞不下,但重要的规则都很直观:

  • 首次访问(PrRd、line 在 I):发 BusRd。 没人持有,行以 E 到达;至少有一个别的缓存持有,两边都到 S
  • 写 Exclusive 行(PrWr、line 在 E):静默地切到 M。 总线零流量 —— 还没人 care。
  • 写 Shared 行(PrWr、line 在 S):发 BusUpgr(或 BusRdX), 强制所有其他 sharer 从 S → I。写的人变 M。这是常见操作里最贵的一种。
  • 外核读一条 Modified 行(snoop BusRd、line 在 M):把脏数据写回(DRAM 追上),同时直接把行给请求方,两边都变 S
  • 外核写任意一条本核缓存里的行(snoop BusRdX / BusUpgr):降为 I
MESI 走读 —— 两颗核、一条 cache line、八次状态转移初始 —— 两核都空CORE AIcached X(stale —)CORE BIcached X(stale —)DRAMX = 0两边都没缓存。DRAM 持有正典值。M = Modified · E = Exclusive · S = Shared · I = Invalid
1 / 8
一步一步走过那个让两颗核私有 L1 保持一致的协议。注意最后两步:一次「静默写」(已经M)在总线上零代价,但紧跟着的外核读就会强制一次写回。 这套模式反复发生,就是 真实共享(true sharing) 的代价 —— 也就是下一节的主题。

Snoop bus vs directory 协议

上面这套是 snoopy 协议:每个缓存听到每次内存事务, 自己决定要不要反应。小核心数下行 —— 总线本来就要带每条 coherence 消息。 超过大约 16 核之后,「广播到每个缓存」就成了主要开销。

多核系统(Intel Xeon、AMD EPYC、Apple M 系列、ARM Neoverse)改用directory 协议:一个集中或分布式的目录, 为每条 cache line 记录当前哪几个核持有它。写只发给这几个核,不广播。 逻辑上还是同样 4 个状态,网络行为完全不同。

MESIF(Intel)与 MOESI(AMD)—— 真实 CPU 跑的是什么

纯 MESI 有个弱点:当某核要读一条在多个缓存里都是 S 的行时,谁来响应? 没有 tie-breaker,可能所有 sharer 都回(浪费),或者请求一路掉到 DRAM(慢)。 两个真实世界的扩展分别用不同方式解决:

  • MESIF(Intel Nehalem 起,2008 前后)。 加一个 Forward 状态 —— 在任一时刻,至多一个 sharer 被指定为 BusRd 的响应者。S→S 迁移照旧,但只有一个缓存回。 严格意义上是 MESI 语义之上的优化。
  • MOESI(AMD K8 起,2003 前后;ARMv8 在不少配置里也用)。 加一个 Owned 状态:一颗核可以持有一条脏行、 并把它直接分享给其他核,而不先写回 DRAM。 脏数据的 cache-to-cache 转发 —— 更快,但 invariant 更严。 一颗核猛写、多颗核读的场景下尤其有用。

要点。「MESI 是大家都学的四状态协议。真实硬件用扩展 —— Intel 加一个 F(Forward)状态,让共享行的读只有一个缓存来回;AMD 加一个 O(Owned)状态, 让一颗核握着脏行同时让别人直接读。同样的四状态直觉,不同的快路径。」

03

代价 —— 弹跳、原子操作、内存模型

Coherence 的正确性靠硬件保证。它也同时是两颗核能干的最贵的事。 知道数字 —— 以及知道哪些模式能绕开它 —— 就是「代码能扩到 32 核」和「代码在 4 核就趴下」的差距。

一次弹跳的代价

每一次让某条 line 的「可写权(M)」从一颗核转到另一颗核,都涉及 snoop、 invalidate 广播(或 directory 查询)、以及数据本身的 cache-to-cache 转发。 现代 x86 上单 socket 内每次弹跳实测大约 30–100 ns,跨 socket 则 150–300 ns。 这给一条争用的 cache line 「每秒能写多少次」定了死天花板:

弹跳时间       一条争用 line 上有效写入 ops/sec
─────────────────────────────────────────────────────────
  ~30 ns         ~33 M ops/sec   (单 socket、最理想)
  ~100 ns        ~10 M ops/sec   (单 socket、典型)
  ~300 ns        ~ 3 M ops/sec   (跨 socket NUMA)

作为对比:一次不争用的 L1 写大约 1 ns。 争用的原子计数器,比不争用的慢两个数量级。 这就是为啥所谓「原子操作 ops/sec 扩展曲线」—— 单个共享计数器 vs 线程数 —— 过了几个写者之后就持平甚至下降

CAS、LOCK CMPXCHG、LDREX/STREX

原子操作是 coherence 最重要的客户。看 C++ 的 compare_exchange_strong(Java 的 CAS、Rust 的 AtomicI64::compare_exchange)。 x86 上编译为 LOCK CMPXCHG:LOCK 前缀告诉缓存通过 coherence 协议 把这一行抓到 M 状态、原子地执行 compare-and-swap(其他核观察不到任何中间状态)、然后释放。 不争用时 ~10–25 cycle(个位数 ns);争用时主要由弹跳成本主导,通常 100 ns 以上。

ARM 做法不同。LDREX(load-exclusive)把这条 cache line 标记为「在监视」;STREX(store-exclusive)试着写,如果这条 line 期间没被别人碰过就原子地成功, 被碰过就失败。CAS 循环就是反复重试直到 STREX 成功。功能等价, 但争用下成本变化更大,因为失败的重试都是浪费的工。

内存序 —— 跟 coherence 分开,代价相关

Coherence 给的是单地址的保证(Section 01)。内存序(memory ordering)—— 通过std::memory_order_*、Java volatile、Rust Ordering::*配置的 —— 管的是跨地址可见性。一致性的成本和底层 coherence 协议密切相关, 但二者是不同的旋钮。

x86 是 TSO(Total Store Order)。读不会和别的读重排; 写不会和别的写重排;读有时可以提前到旧的写之前 —— 但仅限不同地址之间 (store-buffer 把戏)。实际操作里,x86 上大多数 C++ atomic 操作 编译为普通 MOV 指令 —— 不需要显式 fence。 只有 memory_order_seq_cst 的写,后面会附一条 MFENCE(~20–30 cycle)。

ARM 是弱序的。没有显式 barrier,不同地址的读和写, 在其他核眼里可以以任意顺序观察到。一次 acquire 读需要 LDAR; 一次 release 写需要 STLR;完整的 seq_cst fence 需要DMB ISH(通常 20–80 cycle)。这就是为啥「Intel 上跑得过,ARM 上炸」 是最常见的跨架构并发 bug。

争用下能赢的模式

「一个原子计数器、N 个线程」这种争用的修法,是别让它们共享同一个计数器。 给每个线程自己的计数器(放在自己的 cache line 上,见 Section 04)、不用原子操作就地增、 周期性把各线程的本地值规约到全局总数。Linux 的 per-CPU 计数器就是这么干的; Go 的 sync/atomic 统计管道是这么干的;Java 的 LongAdder是这么干的(JDK 明确推荐它替换争用热的 AtomicLong)。

吞吐从「不论多少线程都只有 ~10 M ops/sec」变成「每线程 ~300 M ops/sec、和核数线性」。 代价是 reduce 之间总数有少量滞后 —— 几乎总是可以接受,因为总数本身的读频率本来就低。

要点。「原子操作慢,不是因为指令本身慢。它慢是因为 —— 在争用下 —— 它们通过 coherence 协议跨核序列化。一条热 line 上争用的原子操作每次 ~100 ns; 不争用的 ~1–5 ns。所以问题永远不是『这个是原子吗』,永远是『这个争用吗』。」

04

伪共享 —— 看不见的 bug

两个线程写不同的变量、落在同一条 cache line 上、~30 倍的变慢 —— 源代码里看不出来, 单线程 profile 抓不到,生产里「多线程代码扩到 4 核就趴下」故事背后最常见的无声杀手。

Coherence 按 cache line 粒度 工作,不按字节粒度。 所有常见 CPU(x86、ARM64、Apple Silicon、POWER、RISC-V)上, cache line 都是 64 字节。所以,如果两个线程各自维护自己的计数器 —— 源代码里完全独立、没有共享变量、没有锁、没有原子 —— 但两个计数器变量恰好落在同一条 64 字节 line 上, 任一线程的写都会让对方的缓存副本失效。这条 line 持续乒乓。

这叫 伪共享(false sharing):因为逻辑上根本没在共享。 两个线程都以为它们是独立的;只看物理 line 的 coherence,把它们当成在争用。 所有争用的代价,零真实的共享。

伪共享 —— 两个线程本地计数器之间的 padding 字节padding = 0 字节内存布局(192 字节)T0T164 字节 cache-line 边界估算吞吐20 M ops/secscale: 0 → 700 M ops/sec两个计数器同在 64 字节 cache line 上 —— 任一线程一写,对方的副本就失效。争用(同一行)独立(各自一行)cache-line 边界
1 / 6
同样的负载、同样的代码、同样的硬件 —— 只是两个线程本地计数器之间的字节距离变了。 在 padding = 56(让 T1 越过 64 字节边界的那个值) 处的悬崖,就是一致性流量停止震荡的那一刻。具体 ops/sec 是示意值; ~30 倍的比例是真实可复现的。

64 字节边界在代码里长啥样

把下面这个结构体给同事看,问他哪里有问题:

// 常见的无声杀手布局
struct Counters {
    std::atomic<long> a;   // 字节  0..7  —— 线程 0 写
    std::atomic<long> b;   // 字节  8..15 —— 线程 1 写
};
Counters c;                // 一个结构体,两个字段紧挨着

功能正确,性能灾难。修法是对齐:

// 对齐后 —— 每个计数器独占一行
struct Counters {
    alignas(64) std::atomic<long> a;
    alignas(64) std::atomic<long> b;
};
// C++17 起更可移植的写法:
struct Counters {
    alignas(std::hardware_destructive_interference_size) std::atomic<long> a;
    alignas(std::hardware_destructive_interference_size) std::atomic<long> b;
};

同样的思路在别的语言里:

  • Java:@Contended 注解(JDK 8 起)。 非 JDK 类默认关 —— 需要 -XX:-RestrictContended 开关。 JDK 内部 LongAdderConcurrentHashMap 等都用它。
  • Go:没有内置属性;手动在每个热字段后面加 _pad [56]byte, 或者用一个结构体包装。Go 运行时自己对每 P(per-processor)状态就是这么干的。
  • Rust:给包装类型加 #[repr(align(64))], 或者把字段包进 crossbeam_utils::CachePadded
  • C / 内核代码:GCC/Clang 的 __attribute__((aligned(64))); Linux 直接给了 ____cacheline_aligned 宏专门做这件事。

实际工程里伪共享藏在哪

以下任何一种,编译器都不会警告。单线程 microbenchmark 抓不到任何一种。 每一种都在真实生产系统里咬过人:

  • 线程本地队列存在 vector<Queue> 里。如果 Queue 小到几个能塞进 64 字节槽,相邻队列就会在 coherence 上打架。 解决:给 Queue 加 padding,或者存 vector<PaddedQueue>
  • 同一个环形缓冲结构里的生产者指针和消费者指针。两个最热的字段、被不同线程写、内存里又必然挨着。 Disruptor 的全部性能名声,就建立在「把它们各自放到独占 cache line」上 —— 也是并发数据结构 code review 里被指出最多的一个模式。
  • 序号字段和 flag 字段,被一个写线程和一个读线程分别用。读线程一直在轮 flag;写线程一直在递增序号。同一行 → 两边都卡。
  • 嵌在热数据结构里的统计 / 计数器。一个读多的 hashmap,节点里塞了 hit/miss 计数器 —— 每次更新计数器, 都会让那个本来要读这个节点的线程的缓存副本失效。

怎么检测

Linux 上,perf c2c 是专用工具:报告按变量分组的 cache-to-cache 转发数。 输出里能直接看到地址、源代码行号、弹跳最严重的几条 line。macOS 上 Instruments 的 counters 视图能做类似事。没工具的话,典型气味是「多线程吞吐过了 2–4 线程就不再扩、 CPU 100% 但又看不到明显的锁」。

要点。「伪共享是两个线程写两个不同变量,但这两个变量恰好 落在同一条 64 字节 cache line 上。Coherence 看不出来它们其实没在共享数据, 所以这条 line 一直乒乓。修法:把每线程的热字段对齐到 64 字节边界,各占一行。 C++17 有 hardware_destructive_interference_size;Java 有@Contended;Linux 有 ____cacheline_aligned;别处手动 padding。」

05

速查表

6 道值得能冷讲清楚的核心问题、5 个 code review 时一眼看出来的红旗。 先把问题刻进脑子;答题骨架就跟在后面。

缓存一致性(coherence)和内存一致性(consistency)的区别?

Coherence 是 单地址 保证:每个缓存最终都会以相同顺序看到对某一地址的所有写, 所以你永远不会读到 单个 位置的过期值。Consistency 是 跨地址 保证: 它规定不同地址的写,在其他核眼里以什么顺序变可见。 Coherence 由硬件实现(MESI);consistency 由程序员通过 volatile /std::atomic memory ordering / 内存屏障来配置。

90 秒内解释 MESI。

每个核私有缓存里的每条 cache line 都带 4 个状态之一:Modified(本核独占副本、已写过;DRAM 过期)、Exclusive(本核独占副本、和 DRAM 一致)、Shared(多核有只读副本、全部和 DRAM 一致)、Invalid。本地的读 / 写驱动本核的状态迁移; snoop 到的总线消息(BusRd、BusRdX、BusUpgr、BusWb)驱动其他核引发的状态迁移。 真实 CPU 还会加 F(Intel)或 O(AMD)做额外优化,但语义骨架就是 MESI。

硬件层面 CAS 是怎么工作的?

x86 上是 LOCK CMPXCHGLOCK 前缀让缓存通过 coherence 协议 把目标 cache line 抓到 M 状态、原子地执行 compare-and-swap (任何其他核都看不到中间状态)、然后释放。 ARM 上是 load-linked / store-conditional 对 —— LDREX 把这一行标记为「在监视」、STREX 写,只在期间没人碰过时成功。 不争用时几 ns;争用时主要由弹跳成本主导。

什么是伪共享(false sharing),怎么修?

两个线程写两个不同变量,但这两个变量恰好落在同一条 64 字节 cache line 上。 Coherence 看不出来「其实没在真共享」,所以这条 line 一直乒乓。 修法:把每线程的热字段对齐到 64 字节 —— C++ 用 alignas(64)、 Java 用 @Contended、Linux 用 ____cacheline_aligned、 Rust 用 crossbeam 的 CachePadded、Go 手动加 [56]byte padding。

为啥 std::atomic<int>::fetch_add 经常比 int++ 慢?

普通的 int++ 在 cache 热的本地变量上,就是一次 L1 访问,~1 ns。atomic::fetch_add 编译成带 LOCK 前缀的指令(x86) 或 LL/SC 循环(ARM);即便不争用,也强制对内存的完全 ordering、还可能 flush store buffer。 争用时它通过 coherence 协议把这一行抓到 M 状态,意味着每次调用 30–100 ns 的弹跳。 指令本身不慢 —— 慢的是 它触发的那一堆 coherence 工作

什么场景下你会用 per-thread 计数器 + reduce,而不是共享的 atomic?

写远多于读 + 争用高到 coherence 成为瓶颈的场景。Per-thread 计数器 increment 时零 coherence 流量 —— 每个线程写自己的 cache line —— 只有周期性的 reduce 才付出代价。 吞吐从「不论多少核都持平」变成「随核数线性扩」。 Java 的 LongAdder 就是基于这个洞察;Linux 的 per-CPU 计数器也是。 代价是 reduce 之间总数稍微滞后,几乎所有 metrics / stats 用途都能接受。

Code review 红旗

  • 结构体里 2 个及以上相邻的 std::atomic<T> 字段。它们几乎一定落在同一条 cache line 上。如果不同线程写它们,你就有 false sharing。
  • 读多的数据结构里,节点里嵌了一个热计数器或版本号。每次计数器更新都会让所有正在读这个节点的线程的缓存副本失效。
  • vector<Worker>vector<Queue>, 其中 Worker / Queue < 64 B。相邻元素会落到同一条 cache line —— 连续存储对 cache prefetch 有好处,对 coherence 致命。
  • 同一个环形缓冲结构里,生产者和消费者指针没 padding。并发数据结构 code review 里被指出最多的一个模式。两个必须各自占一条 cache line。
  • 一个 std::atomic<bool> flag 紧挨着它保护的数据。acquire load 这个 flag 和 load 那份数据落在同一行 —— 单写多读时还好,一旦有多写,两边都会因为同一行而无故争用。