先看一个反直觉的现象。两个线程各自累加自己的计数器,互不相干,一个变量都不共享;只因为两个计数器挨着放在同一个结构体里、落在同一条 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,无效):我这儿没有有效副本——从来没有,或者被别人作废了。读和写都得先去要。
摆成一张表,结构一目了然,连空着的那一格也是:
| 干净(和内存一致) | 脏(我改过) | |
|---|---|---|
| 只有我有 | E | M |
| 别人可能也有 | S | ——(MESI 里不存在) |
右下角是空的,因为 MESI 要求写之前必须先独占:脏一定意味着“只有我有”。别的核来读一条脏行,持有者把数据交出去,两边一起变成干净的 S。
真实芯片用的变种,就是在这张表上改动。AMD 的 MOESI 多了一个 O(Owned),正好填右下角:我改过,别人可以有只读副本,内存继续过期,由我负责回答读请求,省一次写回。Intel 的 MESIF 多了一个 F(Forward):一群干净的 S 副本里指定一个专门应答,免得大家抢着答。都不是新机制:一个填空格,一个指定应答者。
为什么要有 E? 它是私有数据的快速通道。我读一条行,发现没人有,就拿 E;以后写它,连“确认没人有”都免了,一条消息都不发。没有 E,第一次写纯私有的数据,也得走一遍“作废所有人”的往返,尽管根本没人可作废。E 只有一个来路:读未命中,而且没人持有。
3. 跟着一条行走六步
两个核 A、B,一条谁都没有的行 X:
| # | 事件 | A | B | 发生了什么 |
|---|---|---|---|---|
| 0 | 开始 | I | I | 谁都没有 |
| 1 | A 读 X | E | I | 没人有;A 从内存取来,独自持有、干净 → E |
| 2 | A 写 X | M | I | E→M,不发任何消息;A 的副本比内存新 |
| 3 | B 读 X | S | S | B 发请求;A 窥探到自己是 M,内存是旧的,A 必须交出数据;两边降为 S |
| 4 | B 写 X | I | M | B 是 S,不能直接写:发作废(数据已经在手里);A → I;B → M |
| 5 | A 读 X | S | S | A 未命中;B 是 M,B 交出数据并降为 S |
| 6 | A 写 X | M | I | 第 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 先把数据交出去。
把这些规则画成一张图:上半部分是这个核自己的读写,下半部分是它窥探到的别的核的请求。
规则就这些。遇到没见过的场景,问三件事:谁在读、谁在写、别人手里是什么状态。
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 里看到什么、怎么改、怎么验证。
- 信号: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。
- 信号:热路径上有
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。