4.4 多核扩展与缓存一致性协议


4.4 多核扩展与缓存一致性协议

本节摘要:多核化让同一行数据在每个核的私有缓存里各存一份副本,没有协调机制的话,一个核的写入别的核看不见,程序会安静地算错。缓存一致性协议的使命是让所有核"透过各自的缓存看到同一个内存"——本节讲清它维护的三条性质、经典四状态协议的状态流转、监听与目录两大实现方案的扩展性分野,以及伪共享这个工程上最常见的坑。

一段安静算错的代码

两个核各自执行一段极其简单的代码:核零反复对变量甲做累加,核一对变量乙做累加。两个变量不同、互不依赖,逻辑上没有任何并发错误。但跑完一核对总数,偶发地少了——不是每次都少,压得越狠越容易少。问题不在软件:两个变量恰好落在同一个缓存行里,核零的缓存为了独占这行而写,核一的副本被作废;等核一再写时,又把核零的副本作废。两边各自基于自己缓存里的旧值累加,一部分更新就这样安静蒸发。这就是缓存一致性问题的标准入场方式:没有协议时,多核的"写"是互相覆盖的关系

一致性协议要维护三条性质,合称一致性:写传播——一个核的写入最终要对其他核可见;写串行化——对同一地址的所有写,所有核看到的顺序相同;读己之写——自己刚写的值自己立刻能读到。三条都达成,程序员才可以假装"只有一个内存",多核编程的心智模型才不至于崩塌。

经典四状态协议怎么转

工业界通行的一族协议给每个缓存行维护四个状态:已修改、独占、共享、无效。状态之间的迁移由本核读写与远程核的探听请求共同驱动。以简化模型看核心循环:

注意几个设计精妙处:独占态是优化——干净且独占时,本核写它不需要任何总线事务,省下的流量是实打实的性能;已修改态兼任写回缓冲——脏行不必立刻写回内存,他核来要数据时再交出,写内存的次数被压到最低。协议设计的每一处都是在"减少昂贵事务"与"保持语义正确"之间议价。

两大实现方案:监听与目录

状态机只是规则,规则的执行机构有两种主流架构。

总线监听:每个缓存控制器都盯着共享总线上的事务,发现与自己持有的行相关就按状态机响应。实现简单、延迟低(一跳广播人人可见),但可扩展性受总线带宽物理限制——每核都要听所有事务,核数一多,监听流量按平方增长。适合核数较少的场合。

目录方案:设一个集中目录,逐行登记"这个块现在被哪些缓存持有、处于什么状态"。读写前先查目录,按登记信息精准发失效或取数请求——点对点通信,不广播。核数扩展性远好于监听,代价是目录本身的存储开销(容量正比于缓存总行数)与多一跳的目录访问延迟。大规模多核与多芯片系统几乎都用目录或其变体。

维度 总线监听 目录方案
通信方式 广播人人听 点对点精准投递
扩展性 核数受限 可扩到众核与多芯片
额外硬件 监听逻辑 目录存储与控制器
延迟 低(一跳) 多一跳目录访问
典型阵地 小规模多核 服务器级与众核

RISC-V 生态两类方案都有成熟实现:小规模开源核常用监听,带缓存一致性的集线器类部件则普遍走目录路线。第八章对比开源核时会看到,是否带一致性支持、用什么方案,是划分核定位档次的分水岭——它直接决定能不能对称多处理器方式跑通用操作系统。

伪共享:一致性的账单转嫁

回到开篇那段安静算错的代码——在现代系统上它的正确性由一致性协议兜底(不会再丢更新),但性能账单转嫁成了伪共享:两个无关变量挤在一行,互相把对方的缓存行"弹"来弹去,一致性事务的 ping-pong 流量让两个核都频繁未命中。修复手段是缓存行对齐:让高频写的独立变量各自独占一行(编译器属性或手工填充),把无关的写隔离开。性能剖析里若看到一致性事务计数异常高而缓存容量未满,第一反应就该查伪共享——第八章的调优演练里有完整的定位链路。

修复的代码形态值得看一眼(对齐属性的示意写法):

// 修复前:两个计数器几乎必然同处一行 struct counters { long a; long b; }; // 伪共享高危布局 // 修复后一:显式对齐每个计数器(按目标缓存行宽对齐) struct counters { long a __attribute__((aligned(64))); // 各占一行 long b __attribute__((aligned(64))); }; // 修复后二:每核一份私有副本,收尾时归并 // 线程只写自己的 local_count,统计阶段再求和——彻底消除跨核弹跳

两种思路的哲学不同:对齐是"把敌人隔开",私有副本是"根本不给敌人见面机会"。高并发计数场景几乎都用后者——不是因为它更巧妙,而是它把一致性流量从每次累加降到归并一次。

目录协议的一个执行细节:所有权迁移

目录方案里有个容易被忽略的细节:写未命中时数据从哪来。慢路径是"向目录要权限——目录通知当前持有者写回内存——内存把数据发给你——你进入已修改态",三段式往返,延迟可观。优化路径是所有权直接迁移:目录指令持有者把脏行直接发给请求者(同时更新目录指向),省掉"先写回内存再从内存取"的一来一回。这个细节决定了目录协议的实际延迟档次,也解释了为什么跨核共享频繁的代码对协议实现的敏感度这么高——同一份协议规范,迁移做不做、怎么做,跑出来的性能能差出一截。评估开源一致性实现时,"写未命中走不走所有权迁移"是含金量很高的一问。

常见问题快答

问:一致性协议保证程序正确吗? 它保证的是"内存视图一致",不保证程序逻辑正确——竞态条件(该加锁没加锁)照样产生错误结果,协议只是让你的错误结果"确定地错",而不是"随机地错"。正确性靠同步原语,那是 4.5 的主题。

问:一级缓存还有共享的末级缓存,中间层怎么办? 多级缓存的每级都要参与协议——通常是"内含式"约定(外层缓存的内容包含内层缓存的内容),探听或目录请求先到共享层,由它转发或代答。层数越多,协议实现越复杂,这也是高端核的内存子系统验证量惊人的原因之一。

💡 关键直觉:一致性协议让"正确性"由硬件买单,但"性能"永远由软件买单。硬件保证你算得对,不保证你算得快——快慢的钥匙在数据布局。

本节要点回顾

  • 多核副本是一致性问题的根源:没有协议,写是互相覆盖的关系,程序安静算错;
  • 三条性质:写传播、写串行化、读己之写,达成后程序员才能假设单一内存;
  • 四状态协议:独占态省事务、已修改态兼写回缓冲,处处是流量与语义的议价;
  • 监听对目录:广播低延迟但不扩展,目录精准投递但多一跳,按核数选型;
  • 伪共享是账单转嫁:正确性硬件兜底,性能要靠缓存行对齐自己救。

一致性解决了"副本最终一致",但没约束"什么时候对谁可见"——顺序问题交给下一节的内存模型实验。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U