低延迟系统开发专题

MESI 缓存一致性

多个核各自缓存同一条缓存行时,硬件怎么让这些副本保持一致:单写者 / 多读者规则,用两个是非题推出 MESI 的四个状态,跟着一条行走六步,每种写到底贵在哪,以及一致性从来没承诺过的两件事——多字读取的原子性,和不同地址之间的先后。

约 10 分钟
本页目录

先看一个反直觉的现象。两个线程各自累加自己的计数器,互不相干,一个变量都不共享;只因为两个计数器挨着放在同一个结构体里、落在同一条 64 字节的缓存行上,速度就可能比分开放慢几倍到十几倍(例子见 #2 2.1)。两个线程明明没有共享任何东西,硬件却让它们互相拖累。

要解释这件事,得先知道多核 CPU 怎么管理同一条缓存行的多份副本。每个核都有自己的 L1、L2,里面存的是内存的副本;两个核都读过同一条行,就各有一份。其中一个一写,另一份就过期了——硬件必须保证没有哪个核拿着过期副本继续用。这套机制叫缓存一致性协议,最经典的就是 MESI。

把它弄懂,很多看似无关的现象就都对上了:伪共享、SPSC 队列为什么要缓存对方的游标(#4),以及读者越多、生产者越慢的 SPMC 队列(#5)。这篇从一条规矩讲起,推出四个状态,跟着一条缓存行走一遍,最后讲它不保证什么——这正是面试最爱追问的部分。

1. 要么一个写者,要么多个读者

硬件保持副本一致的办法,不是“写的人去把别人的副本也改掉”,而是一条更省事的规矩:要写一条行,先让所有别的核把自己那份扔掉,然后你一个人写。

写成不变量(教科书叫 SWMR,单写者 / 多读者):任何时刻,一条缓存行要么只有一个核能写它,要么有任意多个核只能读它,两者不会同时存在。

MESI 就是执行这条规矩的记账方式。每个核的缓存控制器给自己缓存里的每条行贴一个状态标签,同时盯着别的核发出的请求(叫窥探,snoop),决定怎么回应、要不要改自己的标签。标签一共四种:M、E、S、I。

真实芯片不会把每个请求广播给所有核:snoop filter 或目录记着哪些核有哪条行,只问相关的核。这改变的是消息怎么走,不改变四个状态的含义。


2. 四个状态,两个问题

给我缓存里的一条行贴标签,只要回答两件事:别人手里有没有这一行? 我这份和内存一致吗,还是我改过? 再加上“我这儿没有有效副本”,一共四种:

  • M(Modified,已修改):只有我有,而且我改过,内存里是旧的。我可以随便读写,不用告诉任何人;别人要读,必须由我交出数据;被赶出缓存时,先写回内存。
  • E(Exclusive,独占):只有我有,和内存一致。可以读;也可以不通知任何人直接写,写完变 M。
  • S(Shared,共享):可能好几个核都有干净的副本。只能读;要写,得先让其他持有者作废。
  • I(Invalid,无效):我这儿没有有效副本——从来没有,或者被别人作废了。读和写都得先去要。

摆成一张表,结构一目了然,连空着的那一格也是:

干净(和内存一致)脏(我改过)
只有我有EM
别人可能也有S——(MESI 里不存在)

右下角是空的,因为 MESI 要求写之前必须先独占:脏一定意味着“只有我有”。别的核来读一条脏行,持有者把数据交出去,两边一起变成干净的 S。

真实芯片用的变种,就是在这张表上改动。AMD 的 MOESI 多了一个 O(Owned),正好填右下角:我改过,别人可以有只读副本,内存继续过期,由我负责回答读请求,省一次写回。Intel 的 MESIF 多了一个 F(Forward):一群干净的 S 副本里指定一个专门应答,免得大家抢着答。都不是新机制:一个填空格,一个指定应答者。

为什么要有 E? 它是私有数据的快速通道。我读一条行,发现没人有,就拿 E;以后写它,连“确认没人有”都免了,一条消息都不发。没有 E,第一次写纯私有的数据,也得走一遍“作废所有人”的往返,尽管根本没人可作废。E 只有一个来路:读未命中,而且没人持有。


3. 跟着一条行走六步

两个核 A、B,一条谁都没有的行 X:

#事件AB发生了什么
0开始II谁都没有
1A 读 XEI没人有;A 从内存取来,独自持有、干净 → E
2A 写 XMIE→M,不发任何消息;A 的副本比内存新
3B 读 XSSB 发请求;A 窥探到自己是 M,内存是旧的,A 必须交出数据;两边降为 S
4B 写 XIMB 是 S,不能直接写:发作废(数据已经在手里);A → I;B → M
5A 读 XSSA 未命中;B 是 M,B 交出数据并降为 S
6A 写 XMI第 4 步的镜像:A 作废 B

第 3 步是“读命中了别的核手里的 M”,叫 HITM,perf c2c 数的就是它。第 4 步的 S→M 叫升级:数据本来就在,买的只是所有权。

第 1、2 步是一个核自己的事;从第 3 步起,每一步都要和另一个核打交道。下面所有的代价都是它的变形。


4. 规则:读、写、被窥探

上面每一次状态变化,都是三种事件之一。

读

  • 命中(M、E 或 S):读本地副本,不发消息。
  • 未命中(I):发请求。别的核是 M → 它交出数据、降成 S;别的核是 E 或 S → 大家都是 S;谁都没有 → 从内存取,自己拿 E。

写——先独占,四种起点,四种价钱:

  • M:直接写,不发消息。
  • E:直接写,变 M,不发消息。
  • S:给其他持有者发作废、等确认,然后变 M。不搬数据,但要一次跨核往返。
  • I(写未命中):发 RFO(read for ownership,“把这行给我,而且只给我”)。把行取回来,同时作废所有别的副本,然后变 M。

写未命中直接 I→M,没有“先 E 再 M”这一步要付钱。有的图画成 I→E→M,是把“拿到独占”和“改写”拆成两步;作为一个停留的状态,E 只出现在读未命中之后。所以队列的生产者写环里的块,从来不是“E→M 静默写”:它写过、没人碰的行一直是 M;被读者读过,下一圈是 S→M;已经被淘汰,是 I→M。

被窥探(别的核发请求时,我怎么应)

  • 别人要读:我是 M → 交出数据、降成 S;E → 降成 S;S → 不变。
  • 别人要写(RFO 或作废):M、E、S 一律变 I,M 先把数据交出去。

把这些规则画成一张图:上半部分是这个核自己的读写,下半部分是它窥探到的别的核的请求。

MESI 状态转换图:本核读写把行带向 E、S 或 M;别的核读把 M、E 降为 S,别的核写把 M、E、S 都变成 I

规则就这些。遇到没见过的场景,问三件事:谁在读、谁在写、别人手里是什么状态。


5. 一次写的钱花在哪

一次写的价钱 = 取数据的价钱 + 要不要等别的核确认。

  • M 或 E:在本核内,几个周期,不牵涉别的核。
  • S→M:数据在本地,不搬;但其他持有者要确认作废——一次跨核往返。
  • I→M:要把数据取回来,价钱看它在哪:L2 几 ns、L3 十几 ns、别的核的缓存几十 ns、内存一百 ns 左右(典型量级);别人也有时还要作废。

所以 S→M 和 I→M 没有固定的高低,看数据从哪一层来、要不要跨核确认。单次孤立地去内存取数据,I→M 更贵;连续流式写时可能反过来:预取和多个未完成的请求能把内存延迟叠起来,而“每条都要一次确认”叠不起来。在 #5 的 SPMC 实测里,64 MiB 的环、没有读者(基本是从内存取的 I→M)12.6 ns/条;512 KiB 的环、一个忙轮询的读者(基本是 S→M)18.5 ns/条(两组配置不同;归因到 S→M 还是 I→M 是解读,没有读硬件计数器)。

最贵的读是 HITM:要的行在别的核手里是 M,必须由它交出来,是一次完整的跨核搬运。

有代价不等于核在傻等。 普通的 x = 1 先进 store buffer(#2),核继续往下执行,拿所有权在后台办。只有 store buffer 满了,或者遇到带 lock 前缀的读改写和屏障,核才要停下来等。所以在别的核占着的行上做 fetch_add 特别贵:指令做完之前必须一直独占,又不能放进 store buffer 异步办。


6. 三个旧结论,同一台机器

伪共享。 两个核各写自己的变量,变量却在同一条行上。把第 4–6 步里的读去掉:A 写(A 是 M,B 是 I);B 写——B 是 I,发 RFO,A 交出数据并变 I,B 变 M;A 再写……每一次自增都是一次 I→M、一整行搬运,外加对方一次 M→I。 M→I 是乒乓的核心:每个核刚写完就被对方赶下线。perf c2c 数的是这个循环里的 HITM;alignas(64) 让每个变量独占一行来修它。

SPSC 队列的缓存游标(#4)。 消费者每处理一条就写一次自己的读下标,那条行在消费者核里是 M。生产者要是每次入队都去读它,就把它拉成 S,消费者下一次写又得升级——每条消息多两次跨核往返。缓存一份游标副本,生产者只在“看起来满了”时才去读真的那个。

读者越多,生产者越慢(#5)。 实测(Ryzen 5 5600GT,Windows,512 KiB 的环):没有读者 2.8 ns/条,一个忙轮询的读者 18.5 ns/条,五个 28.0 ns/条。没有读者时环放得进 L2,每条行在生产者核里一直是 M。读者一轮询就把行拉成 S;生产者下一圈写每一块都要先升级,读者再去读又把它拉走。五个读者就是五份副本要作废。这是小环、读者紧跟着生产者全速写的情形;写得慢、环很大或读者落在后面时不一样,见 #5 1.6。

HFT 为什么在意。 跨核共享的每一条缓存行,都是延迟账单上的一项。热路径上让每条行只有一个写者(SPMC 环就是这么设计的),并且记住:只读的共享者也会让写者付钱。确实要多线程一起更新的计数器,按线程分片、事后合并,别让一条行在核之间来回传。


7. 一致不等于原子,一致不等于有序

这是面试最爱追问的:MESI 都保证一致了,为什么还要……?

一致不等于原子。 MESI 保证的是:所有核对同一条行上的写有一个公认的先后,不会有哪个核一直拿着过期副本用。它不保证分几步完成的读或写是一个整体。写者用几次 store 更新一条记录,读者用几次 load 读它,两者可以交错:每次 load 读到的都是这条行某一时刻的真值,拼起来却是一半新一半旧。这就是 #5 测到的撕裂读——在故意最恶劣的压力测试下,被接受的读取里约 5.5% 是撕裂的。读者手里有 S 副本也防不住:副本可以在读者读完第一个字段、还没读第二个字段时被作废、换成新的。而且 128 字节的记录跨了两条行,两条行各自一致,合起来没人保证是同一时刻。

一致不等于有序。 MESI 讲的是一个地址上的写。你先写 x 再写 y,别的核能不能先看到 y 的新值、后看到 x 的新值,不归它管。这由核内部决定——让写晚一点发布的 store buffer、让作废晚一点生效的 invalidate queue——还有编译器(#2)。所以经典答案是:MESI 管缓存之间的一致,屏障管核内部读写排队的先后。

“无锁三件套”现在各有分工:lock 前缀让一次读改写不可分割(指令做完之前一直独占这条行);MESI 让每条行的各份副本一致;内存屏障规定不同地址之间的先后。少了哪一件都不行。

应用

第 5 节一次写的价钱,第 6 节的伪共享和按线程分片的计数器,在实际优化里对应“B6 伪共享”和“B7 共享统计计数器”两张卡片:在 perf 里看到什么、怎么改、怎么验证。

B6 伪共享

  • 信号:c2c 某行 Tot Hitm > 5%,同一行里不同偏移被不同线程写;1 核时不显眼,多核才冒出来;以同一 socket 内的 HITM 为主。
  • 原因:缓存行(通常 64 字节)是一致性的最小单位。A 线程写字段 X,B 线程那份整行也失效,要重新从 L3 或内存读。
  • 修法分三步:
    • 先多垫 padding,把打架的字段推到不同的行。这一步只为证明诊断对,先不管省空间;
    • 用 c2c 复测,该行 HITM 降到 1% 以下、吞吐上来才算对。降不下来说明诊断错了:要么是真共享,要么还有别的字段卷进来;
    • 再按访问模式重排:每个线程常写的字段各占一行,多线程只读的字段可以共用一行,很少碰的字段填空位。
  • 更彻底的做法:把线程私有的字段挪进每线程一份的结构体。
  • 验证:c2c 前后对比;用 _Static_assert 守住结构体大小。
  • 原理:#2 第 2 节 伪共享;两个核各写自己的变量时,这条行的状态怎么来回变,见 MESI 专题第 6 节;让对象独占整条行的 alignas(64) 见 #6 1.3。

B7 共享统计计数器

  • 信号:热路径上有 lock add/inc/xadd,没有 cmpxchg 重试循环;c2c 显示同一偏移被很多 CPU 写(真共享);1 核不显眼,多核才冒出来。
  • 原因:每次原子加都要独占缓存行,这一行在核之间来回传,每次接近内存延迟(约 100–300 ns)。改用 relaxed 内存序也没用,硬件照样要独占。
  • 修法按优先级三选一:
    • 自增在一个函数的循环里、热点不超过 2 处:本地变量攒一批,到批量或函数退出时用 relaxed 原子加一次。纯计算循环直接在末尾加一次。
    • 自增散在多处:按 CPU 号分桶。64 字节对齐的统计结构体数组,下标用 sched_getcpu() & 63;所有计数器放进同一个结构体,只调一次 sched_getcpu;读的时候把各桶加起来。
    • 共享库不方便持有全局数组:用 __thread 线程局部变量,线程退出或定期刷到全局。
  • 已经分桶了还在抢:查是不是刷新太频繁,比如每个请求刷一次,拉长间隔。
  • 验证:总数在任意时刻只是近似值,要确认业务能接受;lock 指令从热路径消失;c2c 上该行 HITM 下降。
  • 原理:一次写的价钱从哪来,见 MESI 专题第 5 节;计数器按线程分片、事后合并,见 MESI 专题第 6 节;两个核都写的那个计数器,见 #4 1.2。