缓存一致性 入门
我们的服务器有 32 颗核在处理进来的 HTTP 请求。每颗核都有自己私有的 L1(~32 KB)和 L2(~1 MB);只有 L3 和 DRAM 是共享的。 当两颗核同时碰连接池的引用计数器,凭啥它们看不到对方的过期值 —— 以及为啥这个「凭啥」是两颗核之间能干的最贵的事?5 个 section 把答案搭出来:合同(coherence vs consistency,几乎人人混在一起的那一段);MESI + 可交互状态走读;代价 —— 弹跳、原子操作、 内存模型;伪共享 + 吞吐悬崖 demo;最后一个速查表,6 道核心冷题 + 每道的答题骨架。
合同 —— 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 的物理状态,远比这个抽象凌乱;软件被刻意禁止去看它。
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。
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)状态, 让一颗核握着脏行同时让别人直接读。同样的四状态直觉,不同的快路径。」
代价 —— 弹跳、原子操作、内存模型
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。所以问题永远不是『这个是原子吗』,永远是『这个争用吗』。」
伪共享 —— 看不见的 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,把它们当成在争用。 所有争用的代价,零真实的共享。
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 内部LongAdder、ConcurrentHashMap等都用它。 - 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。」
速查表
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 CMPXCHG。LOCK 前缀让缓存通过 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 那份数据落在同一行 —— 单写多读时还好,一旦有多写,两边都会因为同一行而无故争用。