缓存一致性

两颗核,一个地址,以及阻止它们互相撒谎的那套机器。三件事值得你亲手验证:在单写者不变量下每个 MESI 状态各自允许什么;存储缓冲让哪一种结果变得可达,而单靠一致性协议是禁止它的;以及一条被争用的缓存行那道每秒约 1400 万次写的天花板 —— 堆多少核都抬不上去。

01

一个地址的两份拷贝

单核永远碰不到这个问题。一旦有两颗核,同一个地址就可能同时存在于两个地方,总得有人决定每颗核看到什么。

我们的服务器有 32 颗核在处理 HTTP 请求。每颗核有私有的 L1(约 32 KB)和私有的 L2(约 1 MB);只有 L3 和 DRAM 是共享的。

一次读取取回的不是一个字节,而是这个字节所在的整条 64 字节缓存行。拖动地址,看我们点名的那个字节裹在顺带来的另外 63 个里一起到货:

第 37 字节 —— line 0 整条到货。在这条 tape 上左右拖动;方向键一次挪一个字节
第 37 字节 —— line 0 整条到货

注意:点哪个字节都不改变答案 —— 要第 8 个还是,到货的都是同样那 64 字节。下面每一条规则的单位都是线,不是字节,这也是第 05 节能向两个一个变量都不共享的线程收费的原因。

按播放,看DRAM 里那唯一一份如何变成四份 —— 每颗核依次读 x,各自填满一条私有的缓存行:

第 1 / 4 步 —— 还没有缓存

注意:还没有任何写入,也没有出任何错,机器里已经存着同一个变量的四个版本 —— 其中三份躺在别的核看不进去的私有缓存里。一致性协议就是让这四份保持一致的那套规则,这一页剩下的内容讲的都是这套规则的代价。

把规则拿掉,故障立刻出现。让核 0 走完一次读和一次写,而核 1 保留自己那份拷贝;然后把协议打开,看失效消息抵达:

第 1 / 3 步 —— 核 0 读 x

没有协议时核 1 读到 5 —— 落后一次写,而且全世界没有任何东西会告诉它这件事。有 MESI 时,核 0 的写先把这条线从核 1 手里收走,于是核 1 下一次读会失效并取回 6。这就是全部的合同:一次读返回了一个已经不成立的值,不是软件需要防的事。

一致性是按地址陈述的,它只说两件事:每次对 x 的写最终对所有核可见,并且所有核观察到的对 x 的写顺序完全相同。把核 0 的四次写拖着穿过核 1 的四次,看那唯一一条顺序重排:

核 0 的 4 次写里有 2 次落在核 1 之前。左右拖动,把核 0 的写挪过核 1 的;方向键一次挪一次写
核 0 的 4 次写里有 2 次落在核 1 之前

不管你把边界停在哪里,底下永远只有一行,而每颗核读到的都是同一行。一致性禁止的是:核 1 看到 a3 在 b1 之前,而核 2 看到 b1 在前。顺序不由你挑;它只保证是某一条顺序。

这个保证只覆盖一个地址,对两个地址一句话都没说。拉动滑块,让 flag 的写抢在 data 的写前面 —— 试试 —— 然后读消费者打印出什么:

两次写按程序顺序落地

看着消费者打印 0,而每个单独的地址表现都完美无缺:data 是 42,flag 是 true,没有任何一颗核读到过其中任何一个的旧值。这就是整页立足的那道分界。一致性是按地址给的、自动的;内存序管的是对不同地址的写以什么顺序变得可见,也正是 volatile、std::atomic 的内存序和 mfence 真正在配置的东西。第 03 节就是买它的地方。

02

MESI,以及它守住的那条不变量

每颗核、每条线,四个状态。它们是三个问题的答案,合起来只维持一条不变量。

每个私有缓存里的每条线都带着两位状态。协议就是那条规则:本核读、写、逐出时这两位怎么动,以及别的核动作时它们怎么动。

拉动滑块走过这四个状态,读一下每个状态允许这颗核接下来干什么 ——M 和 E 是允许静默写的两个,I 是什么都没有的那个:

M —— 已修改

注意 M 和 E共有而 S 没有的那一点:只有在别的核都没有拷贝时,这颗核才能不通知任何人就写。这就是那条不变量,值得写成一句断言 ——任何时刻,一条线至多被一颗核以可写方式持有;若被持有,则其他核一份都没有。要么单写者,要么多读者,不会两者兼有。

按播放,跟着一条线走完三颗核能造成的每一次状态转移。每一步放到互连上的消息就画在下面那根线上:

第 1 / 9 步 —— 还没缓存;DRAM 里是 5

九个状态里有七个是被一条总线消息带到的。唯一免费的那次写是第 3 步,当时这条线已经是独占的 —— E 已经证明了没有别人持有它,所以升级到 M 不需要向任何人请示。 E 存在的理由正是这个:让对一条私有线的第二次访问不花钱。

写一条共享线就不免费了,价钱等于它必须先毁掉多少份拷贝。把共享者数量拉上去 —— 试试 —— 数一数消失的那些拷贝:

2 个共享者 —— 其中 1 个会失去这条 line

八个共享者时,一次写要付七次失效加七次确认,然后这次存储才能落下去。所以一个只读为主、每秒写一次的变量没问题,而每秒写一百万次的同一个变量就是一堵扩展性的墙:代价是按写计的,并且随观众人数增长。

这些消息怎么送达,就是笔记本和服务器的分界。把核数拖到 ,比较广播嗅探和目录:

snoop 14,目录 6

看目录那条线保持水平,而广播那条一路爬升。嗅探问每一颗核、每一颗核都要回答 —— 不管它有没有这条线,都是 2(n−1) 条消息;目录知道谁真的在共享,只付 2 + 2s。两个共享者时,从五颗核起目录就严格更便宜,这也是为什么没有 32 核的插槽还在广播。

四个状态表达不了「又共享又是脏的」这件事。所以核 0 写过的那条线一旦多出第二个读者,MESI 就必须把它刷回去。切换协议,一步步走过那次写回,再看之后下一个读者由谁来回答:

第 1 / 3 步 —— 核 0 已经写过这条 line

注意纯 MESI 下的 DRAM:脏行被写回内存,两个缓存最后都干净地持有它 —— 而干净行没有指定的转发者,所以核 1 那次读也只能由 DRAM 来供。 Intel 的 MESIF 在干净的共享者里选一个当应答者(F,forward),第二次读就不出缓存了。AMD 的 MOESI 更进一步:Owner 继续持有脏行并从它那里回答,那次写回压根不会发生。

03

一致性协议看不见的那个缓冲

一台完全一致的机器照样能给你惊喜,因为一次存储并不是在指令退休的那一刻就到达缓存的。

核和它的 L1 之间还坐着一个存储缓冲 —— Skylake 上 56 项, Ice Lake 上 72 项。存储退休进这个缓冲,核就往下走了;缓存行是之后才拿到、写入才是之后才落下的。存储到加载的转发(store-to-load forwarding)保证写者永远读得到自己最新的值。

把还压在缓冲里的存储数量拉上去 —— 推到 —— 看两个读数分开:

56 个表项里有 0 个待落地。沿着这条 buffer 拖动来填满或排空;方向键一次挪四个表项
56 个表项里有 0 个待落地

注意本核读到 100,而其他每颗核仍然读到 44。这里没有任何不一致:在缓存层级看来,那些存储还没有发生。它们会发生,按顺序,大约 15.6 ns 之后 —— 但大家都认同的那个值落后于写者自己看到的值。

这个滞后从外面是能观察到的。两颗核各自先存储、再加载;从「一次落一个存储」切到真正的存储缓冲,把进度拖到最后:

第 1 / 5 步

注意:存储一次落一个时,总有一个会在任何一次加载之前落下,所以 r1 = r2 = 0 不可达 —— 三种结果。给每颗核一个缓冲,两次加载就能在两次存储都还在路上时执行:四种。这就是 x86 的 TSO 唯一允许的那种重排,也是 mfence 存在的全部理由。

把第四种结果拿掉的那道屏障不是一条指令 —— 它是一条指令,加上这颗核之前被允许推迟的一切。屏障已经在了 —— 在它下面把缓冲重新填满,然后再把它拿掉:

56 个表项里有 0 个待落地

指令本身大约 8.3 ns。排空一个满缓冲再加 15.6 ns,合计 23.9 ns —— 大约 86 个周期,而它在源码里只是一行。把它放进一个每秒跑一百万次的循环,你就为没人点名要过的顺序花掉了一颗核的 2.4%。

x86 只给你一种重排要操心,别的架构给你四种。切换架构,逐对走过去 ——程序能观察到乱序的那些,对免费保持程序序的那些:

写 → 写

因为 x86-64 只允许 store→load,五对里有四对是免费的;在那里「正确」的代码到 ARM64 上可能就是错的 —— ARM64 上除了 IRIW 之外每一对都可重排, acquire/release 直接写进了指令本身:ldar 和 stlr。把一个无锁结构从 x86 移到 ARM64 不是重新编译一次,而是重新推导一遍。

04

一次弹跳的价钱

一致性是正确的,也是不免费的。账单的计量单位是一条缓存行易手一次,而且值得用纳秒记住它。

当一颗核要写另一颗核持有的线时,这条线必须旅行一趟:一条失效出去、一条确认回来,再加上数据本身。用单条缓存行来回打乒乓测出来,这一个来回在同一插槽内最好约 30 ns,典型约 70 ns。

拉着滑块走下这道阶梯,看怎样把所有留在本核上的操作甩在后面:

L1 里的普通 ++ —— 0.28 ns

L1 里的一次普通自增是 0.28 ns —— 3.6 GHz 下的一个周期,本页所有纳秒数都按这个主频折算。对本核已经持有的线做一次无争用原子操作是 5.6 ns —— 二十个周期,值得背下来,因为那就是完全没有争用时 std::atomic 的价钱。一次典型弹跳是 70 ns:普通自增的 252 倍。

这个数字是一道天花板,不是一笔税。沿着弹跳曲线拖动手柄,读出一条被争用的线每秒能撑住多少次写:

每次弹跳 70 ns —— 每秒 14.3M 次写。沿曲线左右拖动;方向键一次走 10 纳秒
每次弹跳 70 ns —— 每秒 14.3M 次写

一次弹跳 70 ns 时,一条线每秒吸收1430 万次写 —— 而且这个数不会因为你加核而变。三十二个线程猛砸同一个计数器,拿到的不是 32 倍吞吐,而是同样的 1430 万次,被切成三十二份。

下面那条平线就是这件事。把线程数拉到,看一个共享计数器和每线程一个计数器分道扬镳:

1 个线程 —— 共享 180M,私有 180M

注意共享那条线不只是停止扩展 —— 它是往下掉。一个线程每秒 1.8 亿次,因为那条线从没离开过它的缓存。两个线程加起来是 1430 万次,十六个线程还是同样的 1430 万次。每线程版本达到 29 亿次,差距是 202 倍。

「把它改成原子的」修不好这件事,在有些架构上还会更糟。切换指令,把争用者数量拉上去:

1 个在抢 —— 每次成功要试 1 次

x86 的 lock cmpxchg 在整个读-改-写期间一直持有这条线,所以一次尝试必定成功。ARM 的 ldxr/stxr 在两半之间把线放掉,于是争用者可以把它抢走、store-exclusive 就失败:八个线程竞争时,一个赢、七个重试 —— 一次自增花掉 560 ns 的工作量。原子操作并不消除争用,它只是在你付钱的同时保证结果正确。

05

伪共享

上面算的都是两个线程真的在共享一条线时的账。这一节讲的是它们并没有共享,账单却照样寄来。

一致性协议工作在「行」上,不是在「变量」上。两个在源码里从未被同时提到的计数器,只要落在同样的 64 字节里,照样会互相弹跳 —— 而且程序全程都是正确的,所以没人能靠读代码发现它。

把第二个计数器沿着这块分配拖动,看它跨出第一个计数器那条线的那一刻:

第 8 字节 —— 与 a 同一条 line. 左右拖动第二个计数器;方向键一次挪 8 字节
第 8 字节 —— 与 a 同一条 line

在第 8 字节,两个计数器共享 line 0,这一对每秒跑 1430 万次自增 —— 就是弹跳速率,跟它们是同一个变量时一模一样。到第 64 字节,它们各自坐在一条线上,这一对每秒跑 3.6 亿次。这两个状态之间,源码一个字都没改。

填充就是买下第二个状态的办法,而且用多少不是口味问题。把填充从零往上加,找到那道边 —— 它在处:

0 字节 —— 每秒 14.3M 次。在这些柱子上左右拖动;方向键一次挪八个字节
0 字节 —— 每秒 14.3M 次

因为计数器宽 8 字节,56 是第一个能把第二个计数器推到它自己那条线上的填充量。四十八字节什么也买不到。这是一道悬崖,不是一道斜坡:差一点点到另一条线上并没有部分学分 —— 相隔 56 字节的两个计数器,表现和相隔 8 字节的一模一样,因为两者都还在 line 0 里面,都要弹跳。

结构体变大也不会把差距抹平。往一个没填充的结构体上加线程,看那条共享线拒绝动弹:

1 个线程 —— 14.3M 对 180M

注意:十六个线程挤一条线,加起来还是 1430 万次;十六个线程分到十六条线,就是 29 亿次。填充版多花 1 KB 内存 —— 十六条线而不是两条 —— 换来 202 倍。程序里再没有第二个改动值这个价。

最常见的「不小心买到伪共享」的方式是数组。设定元素大小,数一数有多少个元素起始于 line 0 内:

16 字节 —— 每条 line 4 个

每个元素 16 字节时,有四个共享 line 0。一个 vector<Worker>,每个 worker 只自增自己那个字段,就是四个线程挤在一条线上,而源码里根本没有任何共享变量 —— 并且它是静默失败的:答案全对,四个线程加起来每秒 1430 万次,而四条独立的线本可以给到 7.2 亿次 —— 只有硬件能力的五十分之一。

「静默」这个词值得说准,因为它是关于工具的断言。切换布局,看被占满的核纹丝不动,而HITM load出现又消失:

4 个线程,共用一条线 —— 每秒 14.3M,HITM 每秒 14.3M

第一行就是 CPU 时间采样帮不上忙的原因:两种布局下线程都在核上跑、都在退休指令 —— 只不过那条 line 在别处。真正点名它的计数器是 HITM:一次由别的核持有的 modified 行供给的 load。Linux 上的perf c2c 会统计它,并打印出是哪条 line、哪个字段偏移; VTune 的内存访问分析报的是同一个事件。

06

能扩展的那个形状

这一页上的每一个修法其实都是同一个修法:给每个写者一条自己的线,然后只为合并付一次钱。

分片是它的一般形式,也正是 Java 的 LongAdder 和 Linux 的 per-CPU 计数器在做的事。十六个线程,每个线程一个独占一条线的计数器,再定期折叠成大家真正会读的那个数。能不能成,取决于两个问题:几个计数器,以及多久折叠一次。

把计数器数量拉上去,看最忙的那个被腾空 —— 到时,每个线程都有了属于自己的一条线:

1 个计数器 —— 每秒 14.3M 次

一个计数器是 1430 万次。八个是 1.14 亿次 —— 好一些,但每个分片仍然有两个写者,所以每个分片仍然在弹跳。只有到十六个、谁也不再共享时,曲线才够到 29 亿次。收益不是渐进的:它在最后一个被共享的分片消失的那一刻才到来。

折叠本身也有天花板,而且是同一道天花板。移动折叠间隔,比较这次归约对共享总数提出的要求和一条线能给出的量 ——是第一个装得下的:

每 1 次 —— 总数最多落后 16

因为对外发布的那个总数就是一条线,它每秒最多吸收 1430 万次操作。每次自增都折叠,等于向它要 29 亿次,那分片就白做了。每一千次折叠一次只要 2.9 百万次,装得下 —— 代价是这个总数可能落后 16,000 个计数。对指标来说这是免费的;对一次配额检查来说,这是个 bug。

最后还有一件事,能在你一行代码都没动的情况下把上面全部作废。把第二个线程拖过插槽边界:

socket 0 —— 同一个 socket. 把线程 1 左右拖过边界;方向键同样能挪
socket 0 —— 同一个 socket

两个线程一落到不同插槽上,弹跳就从 70 ns 变成 250 ns,天花板掉了 3.6 倍 —— 而程序里没有任何改动能解释它。这就是为什么一个在单插槽上扩展得很漂亮的基准,到双插槽上就塌了;也是为什么线程绑核和 NUMA 感知的分配应该和填充放在同一场对话里:你精心留给某个线程的那条线,只有在那个线程待在你放它的地方时才便宜。

07

评审清单

在一个 diff 里该找什么,以及值得从这一页带走的五个数字。

这个 bug 在源码里是看不见的,所以评审它必须评审布局,而不是评审逻辑。实践中四种形状几乎覆盖了全部,而前两种正是会被发到生产上的那两种。

逐个走过这四种,读一下哪几种把两个线程的写放进了同一条线 —— 这条带子自始至终都是同样的 128 字节:

两个原子计数器

两个挨着的原子计数器,以及一个环形缓冲的 head 和 tail,都是教科书式的伪共享:不同线程,同一条线。紧挨着它所保护的数据的那个标志位是安全的,因为两者都由同一个线程写。填充过的那一对就是修法的样子,也是四种里唯一要花内存的一种。

在 C++ 里这个修法是一个属性,而这个 bug 是少了一个属性:

struct Counters {          // ✗ one line
  std::atomic<uint64_t> a; //   bytes 0..7
  std::atomic<uint64_t> b; //   bytes 8..15
};

struct alignas(64) Padded {  // ✓ one each
  std::atomic<uint64_t> v;
};

编译器守得住这个对类型的承诺:sizeof 变成 64,步长就是整整一条线。手写的填充什么都不承诺。把填充留在 48 字节,拖动分配器给你的那个基地址 ——:

基地址 0 —— 同一条 line

看同一个结构体改主意。基地址为 0 时两个计数器相隔 56 字节,双双落在 line 0;基地址为 16 时,边界正好落在它们中间,就不再同线。源码一个字都没动 —— 答案在地址的低六位里,所以这个 bug 这一次跑出来、下一次就藏起来,抓到过它一回的测试不会再抓到第二回。

唯一算得上承诺的是隔开整整一条线,而这条线有多长,源码是看不见的。把填充调回第 05 节买下的,再换掉它底下的机器:

56 字节 —— 各占一条 line. 把第二个计数器沿着 tape 拖动;方向键一次挪八个字节
56 字节 —— 各占一条 line

因为 Apple Silicon 的粒度是 128 字节,在 x86-64 上刚刚好的填充又把两个计数器塞回了同一条线,同一个二进制在服务器上快、在笔记本上慢。把填充拖到 120 字节,它们又分开了 —— 这正是重点:这个量是机器的属性,不是源码的属性,而alignas(std::hardware_destructive_interference_size) 是唯一一种「问编译器」而不是「猜」的写法。五个数字撑起这一页:一条线是 64 字节(Apple Silicon 上是 128 —— 用 getconf LEVEL1_DCACHE_LINESIZE 问一下就知道),一次无争用原子操作是 20 个周期,同插槽弹跳约 70 ns,跨插槽弹跳约 250 ns,而一条被争用的线的上限在每秒 1400 万次写左右 —— 不管你往上堆多少核。

红旗

  • 结构体里两个或更多相邻的 std::atomic 字段,由不同线程写。
  • 同一个环形缓冲结构体里的生产者下标和消费者下标,中间没有填充。
  • vector<T> 且 sizeof(T) < 64,每个元素各有各的写者。
  • 读多结构的节点里嵌了一个热计数器或版本号字段 —— 每次更新都会让这个节点对所有读者失效。
  • 填充量写成了字面量,而不是问平台要的 —— alignas 绑的是类型不是分配,而在 C++17 的对齐版 operator new 之前,连 vector<Padded> 都可以无视它。