H24

缓存一致性

点击正文段落选定收藏或划线位置;未选定时使用屏幕内第一段。

课内目录

1 多核为什么需要一致性

把 1 台 8 核工作站拆开看,主板上可能只有 1 个 CPU 插槽、2 条 DDR5 内存和 1 块 PCIe 固态硬盘,程序却能同时在 8 个核心上运行。每个核心为了少等内存,会把近期使用的数据放进靠近自己的缓存。实际处理器常有私有的 L1、L2,以及被多个核心使用的末级缓存,具体共享边界依型号而变。这里的关键不是缓存有几级,而是同一个内存地址可能同时出现在多个核心附近。

假设内存地址 X 保存温度采样结果,初值为 20。核心 A 和核心 B 都读过 X,于是各自缓存了它。随后 A 把 X 改成 21。如果 B 可以一直使用旧副本,程序看到的共享内存就像分裂成了不同版本。缓存一致性要协调这些副本:哪个核心可以修改,其他副本何时失效,后续读取应从哪里得到有效数据。程序不必手动寻找每个缓存里的 X,但协调过程会占用硬件资源。

协调通常以缓存行为单位,而不是以程序变量为单位。许多通用处理器采用 64 B 缓存行,本课实验按这个规格说明;真实机器仍要核对其文档。1 个 8 B 整数只占其中一部分,另外 56 B 可能放着别的字段。即使 A 与 B 操作不同整数,只要它们落在同一行里,硬件仍需要协调整行的写权限。这是后面假共享问题的根源,也是“变量没有重叠”仍会变慢的原因。

一致性首先约束同一个地址上的读写关系,内存一致性模型则规定不同地址上的访问允许按什么顺序被观察。两者相关,却不能相互代替。例如先写数据、再写就绪标志,读者是否能据标志安全读取数据,需要语言与硬件提供的同步规则。即使机器能保持缓存一致性,普通变量之间的数据竞争仍可能使程序行为不成立。学习硬件机制不是获得绕过原子操作、锁或语言内存模型的许可。

也不要把“共享”理解成“所有内容永远在内存里同步更新”。最新数据可能暂时只在某个核心的缓存中,其他请求者通过互连得到它,内存里的副本可以晚些更新。这里的互连是芯片内部连接核心、缓存和内存控制器的通路,不等于主板上的 PCIe 总线。固态硬盘中的文件也不会因为 CPU 缓存一致性自动获得持久化保证。我们接下来讨论的是运行时共享内存中的可见副本与访问权限。

多个核心围绕缓存行协调有效副本
多个核心围绕缓存行协调有效副本

2 MESI 状态机直觉

MESI 可以作为理解缓存行权限的入门模型。M 是 Modified,表示本核心持有已修改的数据,内存副本可能落后;E 是 Exclusive,表示本核心独占有效且干净的副本;S 是 Shared,表示本核心有有效的干净副本,其他核心也可能有;I 是 Invalid,表示本地不能用这份内容满足读取。状态描述的是某行在某个缓存中的处境,不是给整个核心贴标签,也不是应用程序可以直接设置的变量属性。

先看读取。假设 A、B 中都没有 X 所在行,A 发起读请求,若系统确认没有其他缓存持有副本,可以把它交给 A 并置为 E。A 只是读取时,不需要让每个核心都复制这行。随后 B 也来读取,A 的 E 可以变为 S,B 得到 S,双方都能在本地反复命中。这说明共享读取通常很便宜:权限建立后,不必让每次读取都穿过芯片互连。

再看写入。A 若已经持有 E,可以在这个简化模型中转成 M 并修改;如果 A 持有 S,就要先获得独占写权限,让其他共享副本失效。请求发出后,还需要等待协议要求的确认,不能在其他核心仍可随意使用旧副本时宣布取得所有权。完成后 A 是 M,B 是 I。A 接着修改同一行多次,通常可以继续在本地进行,成本不必按每次写入都重复支付。

如果 B 此时读这行,系统必须取得 A 的最新数据,不能直接把落后的内存副本当作正确答案。在简化 MESI 中,A 提供最新内容并转成 S,B 得到 S,干净状态所需的更新由协议完成。具体数据经过哪级缓存、是否由共享缓存转发、内存何时收到更新,都取决于实现。真实芯片还可能采用扩展状态与复杂的瞬态状态,本课只保留足够解释性能的稳定状态和权限变化。

动画里值得盯住的不是箭头多少,而是“读者可以有多个,写者要取得独占权限”。E 与 M 都允许本核心独占访问,但数据是否干净不同;I 则不表示字节被物理擦除,只表示它们不能作为有效副本使用。把失效误读成清空,会让人误以为协议在不停搬空缓存。硬件主要在管理有效性、所有权与必要的数据传递。

最后把模型和程序正确性接起来。2 个线程对普通整数执行自增,即使每次写入都符合 MESI,也可能同时读到旧值,再先后写回相同的新值,造成更新丢失。原子读改写把读取、计算、写入纳入规定的原子语义;缓存一致性帮助实现这个过程,却没有把任意源代码表达式变成原子操作。因此“值正确但很慢”和“值已经算错”必须分开判断。

简化 MESI 中读取共享与写入独占的迁移
简化 MESI 中读取共享与写入独占的迁移

3 侦听与目录的取舍

知道需要协调以后,还要决定请求该送给谁。侦听的直觉是让相关缓存观察一致性请求,判断自己是否持有目标行,再作出响应。可以把它想成在规定范围内发布消息:持有副本的缓存需要参与,不持有的缓存也可能花资源判断。早期共享总线使这种组织方式容易理解;今天的芯片内部互连可以更复杂,侦听也不意味着所有设备真的接在同一条广播电线上。

目录的直觉是先查记录。目录维护某行可能有哪些共享者、谁拥有写权限等信息,请求可以据此发送给有关参与者。比如 16 个核心中只有 2 个读过某行,后来的写请求不必让其余 14 个核心都参与同样的查询。代价是目录本身需要存储空间、查找时间和更新机制;记录的精细程度有限时,还可能需要扩大查询范围。目录不是免费通讯录,也不是应用能直接读取的共享变量表。

侦听的直观优势是组织容易理解、在较小范围内可以高效协调;规模变大时,查询与响应压力可能增长。目录能减少无关参与者,却增加状态管理和路径上的工作。真实系统经常把不同机制组合使用,例如在局部范围采用某种侦听,在更大范围借助目录或过滤结构。只凭“采用目录”或“采用侦听”无法排出机器性能名次,工作负载的共享方式同样重要。

把视角移到服务器形态。单插槽 8 核小型服务器,核心间沟通可能都发生在同一颗封装内部;双插槽机架服务器中,同一行还可能跨处理器插槽转移,经过专用的插槽互连。即便内存容量都是 256 GB,缓存共享边界、内存控制器位置、核心数量也可能不同。主板有多少 DIMM 插槽、内存插在哪个 CPU 附近,会影响后续讨论的访问位置,不能只看容量与 CPU 主频。

有 100 Gb/s 网卡并不表示核心间缓存行能以网卡标称带宽交换。网卡通过 PCIe 与系统连接,CPU 内部一致性互连属于另一个层次。许多服务器支持设备 DMA 与 CPU 缓存的某种一致性协作,部分嵌入式板卡则需要驱动显式处理缓存维护。是否一致、哪些区域适用、何时需要同步,都由平台和驱动接口规定。应用应使用成熟驱动与缓冲区接口,不能从“CPU 支持 MESI”推导出“任何外设写完就随处可读”。

4 动手:跟踪一次假共享的状态迁移

准备 1 台至少有 2 个物理核心的机器,以 2 个工作线程各维护自己的 8 B 计数器。先把计数器放在同一个、按 64 B 对齐的结构起点附近,让偏移分别为 0 和 8;再把它们放到不同的、各自按 64 B 对齐且步长为 64 B 的槽位中。前提是机器的缓存行为 64 B,否则布局要跟随实际规格调整。仅在字段之间加 56 B 而没有核对基址与数组步长,并不能完整保证隔离。

为了让实验有定义且确实产生内存操作,可使用 C++ 的无锁 64 位原子计数器,以 relaxed 原子加更新,主线程在工作线程结束后读结果。这里选 relaxed 是因为只需各计数器的原子更新,不用它们发布其他数据;它不会取消缓存一致性带来的写权限竞争。若平台的该原子类型并非无锁,运行库内部机制会改变实验含义,需要记录并换用合适平台。不要把普通局部变量循环自增当成等价测试,编译器可能把大量更新合并掉。

每个线程执行 10000000 次更新,分别固定到同一插槽中的 2 个不同物理核心,并让它们在准备完成后尽量同时开始。避开同一物理核心上的 2 个硬件线程,否则共享执行资源会混入另一类瓶颈。计时区间只覆盖工作阶段,不包含线程创建、打印与文件写入。开始前检查地址与行内偏移,结束后检查每个计数器是否达到目标值,再比较连续多轮的耗时分布。

现在跟踪同一行中的 2 个计数器。A 第 1 次更新自己的字段,取得该行的 M;B 更新自己的字段时,也需要整行独占权限,使 A 的副本失效。A 下次更新又把所有权取回来。它们没有交换业务数据,却不断交换写权限。硬件必须这样做,因为它只看到整行被写,不能把 64 B 自动拆成若干个互不干扰的独立权限区域。

分开缓存行后,A 与 B 各自取得所需行的独占权限,后续更新可以长时间停留在本地。结果值没有变化,通信模式却变了。这个对照比“加更多线程看看”更有解释力,因为它直接改变了被怀疑的共享粒度。动画按 A、B 轮流请求画出最容易理解的迁移;实际执行可能让某个核心连续更新许多次,所有权转移次数不能直接等同于源码中的自增总次数。

如果两种布局差异很小,也不要先宣布假共享不存在。可能线程没有真正并行,任务时间太短,线程映射未生效,机器的缓存行规格不同,或原子操作实现带来其他开销。记录核心拓扑、线程位置、地址布局、编译优化与总操作数,才能判断这个实验实际比较了什么。我们需要的是可解释的对照结果,不是某个固定的加速倍数。

从字段地址到缓存行再到所有权转移的观察链
从字段地址到缓存行再到所有权转移的观察链

5 一致性流量与性能悬崖

缓存命中率高并不保证多核程序扩展得好。某条指令需要的数据可能就在附近,但核心仍须等待独占写权限;某行刚到手,又被另一个核心请求走。除了数据本身,系统还要传递请求、失效通知与确认。某些性能工具把与其他缓存中已修改数据相关的响应归入特定事件,但名称与含义依处理器而变。只盯着内存读带宽,容易漏掉这些等待。

可以建立一个刻意简化的估算:假设热点行每次所有权交接平均占用 80 ns,而且这些交接在这个热点上串行,则每秒交接量级约为 12500000 次。如果每次只完成 1 个 8 B 更新,业务有效进度看起来很小,却已接近这条串行路径的能力。80 ns 是教学假设,不是处理器承诺;真实交接受拓扑、负载、协议状态与队列影响,不能用它反推出某款 CPU 的规格。

这种瓶颈会呈现性能悬崖。1 个线程运行时,热点行可以留在本地;增加到 2 个核心后,开始交接;增加到 8 个核心后,更多请求争夺同一行,等待增长。吞吐可能先上升、随后趋平,甚至下降,而总 CPU 使用率仍很高。使用率表示核心在执行或被计入忙碌,并不直接表示有多少业务工作完成,更不意味着内存通道已达到标称带宽。

滑块把每次交接前能完成的本地更新数作为控制量。若先完成 1 次更新就交接,通信成本几乎由每次更新承担;若本地累计 64 次再合并,交接成本被更多工作分摊。这个模型帮助理解批量的价值,但批量改变了全局统计的及时性,也可能需要线程结束、超时刷新或读取汇总等规则。它适合累计指标,不一定适合每次操作都要求立即生效的余额或严格配额。

增加内存频率不一定能修好这种问题。若争夺的是缓存行所有权,瓶颈可能位于核心互连、插槽链路或串行更新,而不是 DDR 数据总线。换更快的固态硬盘也不会直接改善热点行的交接。诊断时先明确受限资源:是工作集太大引起内存访问,还是共享写入引起协调。它们可能同时存在,但相应的布局、分区和硬件选择不同。

6 常见坑:用单线程思维估多核扩展

常见的估算是:1 个核心每秒处理 1000000 条记录,8 个核心就应处理 8000000 条。这个乘法隐含了工作可以独立分配、每份资源都能随核心数扩展、共享成本不变等条件。只要每条记录都更新同一个全局计数器,核心数越多,围绕该计数器的协调就越密集。单线程测到的局部缓存优势,无法代表共享写入时的路径。

另一个坑是把原子操作视为低成本替代品。原子自增可以避免丢失更新,但多个核心仍需争夺同一行。使用 relaxed 可能减少某些顺序约束,却不允许多个核心同时把同一计数器当作私有变量更新。把所有锁替换成原子,也可能把原先明显的临界区变成分散在多个调用点的热点,让代码看起来并行,实际仍围绕同一个地址排队。

还有人只检查“每个线程是否有独立对象”,忽略独立对象在内存中的邻接关系。数组中相邻的线程统计槽位、队列的生产位置与消费位置、对象里的锁字段与只读元数据,都可能共享缓存行。写字段会使旁边读字段的副本失效,读者随后需要重新取得数据。把所有字段都填充到 64 B 也并非通用答案,因为更大的对象会降低缓存容量利用率,增加遍历流量。

评估时应先建立 1、2、4、8 个工作线程的吞吐与每项任务耗时曲线,同时记录线程布局。先让线程留在同一插槽,再做跨插槽对照;先验证工作量和结果一致,再解释性能。若测试中同时改变数据规模、输入分布、线程数与内存分配方式,就很难知道是哪种共享关系发生了变化。重复测量是为了看波动范围,不能用最快的那次结果掩盖不稳定性。

排查工具也有边界。操作系统提供的缓存未命中、周期与指令等统计可以作线索;支持缓存行争用分析的工具可能帮助定位地址和访问线程。它们往往需要权限、调试符号与平台支持,事件口径还可能不同。工具没报出某个事件不等于没有争用,某个事件增加也不等于已经证明假共享。地址布局对照、线程放置对照和实际吞吐变化,才让证据相互支持。

多核扩展变差时逐步缩小问题范围
多核扩展变差时逐步缩小问题范围

7 案例:锁与共享计数器的一致性风暴

考虑 1 台单插槽 16 核服务器,运行来自 PCIe 网卡的数据包处理程序。网卡把接收数据交给驱动管理的缓冲区,8 个工作线程分别处理队列中的数据。为了显示总处理量,每处理 1 个包,线程都对全局原子计数器加 1;为了管理共享空闲对象池,还要进入由 1 个锁保护的短临界区。数据包分到不同线程了,计数与对象池却仍是共享写热点。

低负载时,线程之间有空隙,计数器与锁的交接成本可能被其他工作掩盖。包率增加后,线程更频繁地请求同一批缓存行。锁成功获取时需要原子操作,锁释放也会改变共享状态;等待线程如何轮询、失败重试与退避,会影响一致性流量。不能把所有自旋读取都画成数据不断来回传输:在状态稳定时,共享读可以本地命中,但锁状态改变和争夺写权限仍会带来协调。

如果锁与计数器恰好位于同一行,问题还会叠加。某个线程更新计数器,也会影响其他线程对锁所在行的缓存;某个线程释放锁,又会影响读取统计的线程。即使把它们拆开后仍有真实共享,拆分也能消除两类用途之间不必要的耦合。这里要区分真实共享与假共享:所有线程更新同一个总数是真实共享,不同字段碰巧在同一行才是假共享。

调整可以先从业务语义允许的地方开始。把吞吐统计改为每线程计数,线程本地累计后定期发布,由观察线程汇总;把对象池拆成线程本地小池,成批向公共池借还。计数槽位按实际缓存行规格布局,避免发布时互相干扰。这样既减少热点更新次数,也缩短公共池被访问的频率。若统计线程持续高速读取每个工作线程刚写的行,仍会制造共享,因此采样频率也应纳入设计。

验证需要同时看吞吐、尾部延迟、统计滞后和内存占用。总吞吐提高却让某些队列长期拿不到对象,不能算完成优化;每线程缓存保留太多对象,也可能让公共池过早耗尽。线程退出时要汇总剩余计数并归还资源,异常路径也应有明确规则。采用分片以后,“全局总数在什么时刻准确”成为接口约定的一部分,不能悄悄把即时值改成近似值。

8 把一致性成本写进并行设计

设计并行程序时,可以从“谁写、谁读、多久交换”开始,而不只是画线程数量。对每个高频数据结构标出唯一写者、多写者或只读者,再标出读者需要多新的值。如果队列分区后能让每个工作线程拥有自己的数据,就优先让所有权在较长阶段内保持稳定。在线程之间交付完整任务或批量结果,往往比让它们不断修改同一组细小字段更容易控制协调成本。

数据布局应服务访问关系。频繁一起读取的字段可以靠近,分别由不同核心频繁写入的字段则应考虑隔离。以 64 B 行为例,8 个 8 B 统计项挤在同一行很紧凑,却可能让 8 个写者争夺;各占 64 B 会增加到 512 B,需要在争用收益与容量成本之间选择。只有确定写入频率、线程映射与热点位置后,这种空间换通信的取舍才有依据。

同步结构也要有边界。锁保护的状态应尽量明确,临界区内避免与保护目标无关的慢操作。对可以分区的数据采用分区锁,对可归约的结果采用局部累积后汇总。但若程序需要对多个分区执行统一事务,拆锁可能增加正确性复杂度;若操作必须严格按全局次序执行,简单分片也无法保留原语义。性能方案必须先列清哪些约束不可改变,再选择共享方式。

容量规划可以加上共享写入预算。比如每秒处理 2000000 个任务,每个任务都碰 3 个全局热点,就可能形成每秒数百万次共享更新请求;如果每 64 个任务合并一次统计,统计路径的更新频率会明显下降,但并非所有热点都会随之减少。预算记录的是待验证的数量级,不是把请求数直接当成总线事务数,因为缓存状态与执行交错会影响真实传输。

最后为扩展性写验收条件。规定在哪种服务器、多少物理核心、哪个插槽范围内测试,列出吞吐、延迟、正确性与内存占用的目标;保留紧凑布局与隔离布局、单写者与多写者的对照。不要用某个不可移植的事件计数作为唯一验收线。可迁移的结论应该是“减少了跨核心共享写入,并在目标负载下改善了结果”,而不是“所有机器都会获得同样倍数”。

9 从一致性走向 NUMA(H25)

到这里,我们一直在问同一行的有效数据和写权限怎样协调。接下来还要问:物理内存在哪儿?在双插槽服务器中,每个处理器通常连接自己的内存通道,一个核心访问另一插槽附近的内存,可能经过插槽互连。系统仍可提供一致的共享地址空间,但访问代价取决于位置。这就是理解非统一内存访问,也就是 NUMA 的入口。

一致性和 NUMA 会在同一次访问中相遇,但解决不同问题。A 读某行,首先可能受到有效副本所在位置的影响;若需要访问内存,还会受到该物理页所在节点的影响。即使没有任何其他核心写这行,远处内存也可能更慢。反过来,即使数据对应的内存在本地,多核争夺同一行仍会昂贵。不要把所有跨插槽变慢都归结为“内存放远了”,也不要都归结为假共享。

这也解释了为什么只固定线程位置不够。线程在哪个核心运行,数据页在哪个内存节点分配,是相关但不同的条件。某些系统的页放置受首次触碰影响,程序初始化方式就可能影响后续访问;具体策略还会受到操作系统、内存策略与迁移机制影响。H25 将把线程、页与节点放在同一张图里,讨论如何测量局部与远程访问,而不是在本课展开操作细节。

为下一课保留本课实验记录:缓存行规格、物理核心与插槽拓扑、线程绑定方式、计数器地址布局和不同线程数下的曲线。再增加数据页所在节点的信息,就能区分“核心在抢同一行”与“核心在读远处的页”。这种分层判断能避免盲目升级硬件,也能帮助解释同一程序为何在小型工作站表现正常,搬到双插槽大内存服务器后扩展变差。

放进方案里

在并行方案中增加一段“共享写入与数据归属”,先写清目标机器:例如单插槽 16 核服务器、64 B 缓存行、128 GB DDR5 内存、PCIe 网卡,以及工作线程实际使用的物理核心范围。缓存行规格和拓扑来自待部署机器的资料或查询结果,不能从内存容量推测。再列出高频共享对象,分别记录写者、读者、更新频率与准确性要求,让评审能看见哪些路径需要跨核心协调。

对全局计数器,可以明确采用每线程累积、每 64 次任务发布的初始策略,并规定监控值允许的滞后、低流量时的刷新周期和线程退出时的汇总规则。对必须立即生效的配额,则保留满足正确性的同步路径,不能套用统计方案。字段布局注明对齐与槽位步长,容量估算包含填充开销;锁、计数器与只读配置是否同处缓存行,也作为检查项写进结构说明。

验证计划以相同输入比较 1、2、4、8 个线程,先在同一插槽测试,再按部署需要增加跨插槽对照。记录吞吐、尾部延迟、最终结果、内存占用和统计滞后,并用紧凑与隔离布局检验假共享假设。性能事件只作辅助证据,注明工具与平台口径。若加线程后吞吐下降,先检查共享写入、线程位置与地址布局,再判断是否需要更多内存带宽。

上线前约定回退条件:若分片导致统计语义不满足要求,或本地对象池使内存占用越界,就调整批量与容量,必要时恢复原同步路径。保留参数、测量环境和基线结果,使后续换 CPU、换主板或扩到双插槽时可以复测。方案的交付物应说明成本从哪里减少、为此付出了什么,以及哪些正确性条件始终必须满足。

10 小结:下一步 NUMA(H25)

缓存一致性把多个缓存里的同一地址协调成可用的共享内存,MESI 提供了理解读共享与写独占的直觉。性能问题常出现在缓存行被多个核心频繁争夺时:真实共享需要减少更新频率或重新分工,假共享还可以通过布局隔离。原子保证与同步语义不能省略,一致性也不等于持久化。

检查自己能否解释 3 件事:为什么不同变量仍会争夺同一行,为什么 relaxed 原子计数器仍可能扩展很差,为什么增加 DDR 带宽未必改善热点行竞争。下一课 H25 将把位置加入模型:线程在何处运行,内存页归哪个节点,以及如何区分远程内存访问与缓存行交接的成本。

同阶段相关课

AI 答疑

默认关闭。启用后,只发送本课标题、最近阅读的屏幕内正文摘录(最多 6000 字)与本次问题。

配置自己的服务