缓存一致性入门
🤖 Claw创作 - 本文由OpenClaw AI助手协助翻译,深入解析多核系统中的缓存一致性机制
本文翻译自Fabian Giesen (ryg)的经典文章,详细介绍了多核系统中的缓存一致性原理,包括MESI协议、内存模型以及硬件如何保持多个缓存同步。
📋 目录¶¶
🎯 引言¶¶
我计划写一些关于多核场景下数据组织的内容。在开始写第一篇文章时,我很快意识到需要先介绍一些基础知识。这篇文章就是为此而写的。
🧠 缓存基础¶¶
这是关于CPU缓存的快速入门。我假设您了解基本概念,但可能不熟悉一些细节。(如果您已经熟悉,可以跳过本节。)
在现代CPU中,(几乎)所有的内存访问都要经过缓存层次结构。有一些例外情况,如内存映射I/O和写组合内存,它们至少绕过了这个过程的部分环节,但这两者都是特殊情况(从大多数用户模式代码永远不会看到的角度来说),所以本文中我将忽略它们。
CPU核心的加载/存储(和指令获取)单元通常甚至无法直接访问内存——这在物理上是不可能的;必要的导线不存在!相反,它们与L1缓存通信,L1缓存应该处理这些请求。大约20年前,L1缓存确实会直接与内存通信。而现在,通常涉及更多的缓存级别;这意味着L1缓存不再直接与内存通信,而是与L2缓存通信——L2缓存再与内存通信。或者可能与L3缓存通信。您明白这个意思。
缓存被组织成"行",对应对齐的内存块,大小为32字节(较旧的ARM、90年代/21世纪初的x86/PowerPC)、64字节(较新的ARM和x86)或128字节(较新的Power ISA机器)。每个缓存行知道它对应哪个物理内存地址范围,在本文中,我不会区分物理缓存行和它代表的内存——这有些草率,但这是常规用法,所以最好习惯它。特别是,我将使用"缓存行"来表示内存中适当对齐的字节组,无论这些字节当前是否被缓存(即是否存在于任何缓存级别中)。
当CPU核心看到内存加载指令时,它将地址传递给L1数据缓存(或"L1D\(",利用"cache"和"cash"发音相同的双关语)。L1D\)检查是否包含相应的缓存行。如果不包含,整个缓存行将从内存(或下一级缓存,如果存在)中获取——是的,是整个缓存行;假设是内存访问具有局部性,所以如果我们正在查看内存中的某个字节,很可能很快就会访问它的邻居。一旦缓存行存在于L1D$中,加载指令就可以继续执行其内存读取。
只要我们处理的是只读访问,一切都很简单,因为所有缓存级别都遵守我将称之为基本不变式的原则:
基本不变式:在任何缓存级别中存在的所有缓存行的内容,在任何时候都与相应地址的内存值相同。
一旦我们允许存储(即内存写入),事情就变得有点复杂了。这里有两种基本方法:写通过和写回。
📝 写通过缓存¶¶
写通过是较简单的一种:我们只是将存储传递到下一级缓存(或内存)。如果我们缓存了相应的行,我们更新我们的副本(或者甚至只是丢弃它),但仅此而已。这保持了与之前相同的不变式:如果缓存行存在于缓存中,其内容始终与内存匹配。
🔄 写回缓存¶¶
写回则更复杂一些。缓存不会立即传递写入。相反,这样的修改会局部地应用于缓存的数据,相应的缓存行被标记为"脏"。脏缓存行可以触发写回,此时它们的内容被写回内存或下一级缓存。写回后,脏缓存行再次变为"干净"。当脏缓存行被逐出(通常是为了在缓存中为其他内容腾出空间)时,它总是需要先执行写回。
写回缓存的不变式略有不同:
写回不变式:在写回所有脏缓存行之后,在任何缓存级别中存在的所有缓存行的内容都与相应地址的内存值相同。
换句话说,在写回缓存中,我们失去了"在任何时候"这个限定词,并用一个更弱的条件替换它:要么缓存内容与内存匹配(这对所有干净的缓存行都成立),要么它们包含最终需要写回内存的值(对脏缓存行而言)。
写通过缓存更简单,但写回有一些优势:它可以过滤对同一位置的重复写入,并且如果缓存行的大部分在写回时发生变化,它可以发出一个大的内存事务而不是几个小的事务,这样更高效。
一些(主要是较旧的)CPU在所有地方都使用写通过缓存;一些在所有地方都使用写回缓存;一些有更简单的写通过L1\(,由写回L2\)支持。这可能会在L1\(和L2\)之间产生冗余流量,但在传输到较低缓存级别或内存时获得写回的好处。我的观点是,这里有一整套权衡,不同的设计使用不同的解决方案。也没有要求所有级别的缓存行大小必须相同——例如,CPU在L1\(中使用32字节行,在L2\)中使用128字节行并不罕见。
为简化起见,本节省略了:缓存关联度/集合;写分配与否(我描述了没有写分配的写通过和有写分配的写回,这是最常见的用法);未对齐访问;虚拟寻址缓存。如果您感兴趣,可以查找所有这些内容,但在这里我不会深入讨论。
🔗 一致性协议¶¶
只要那个单一的CPU核心在系统中是独立的,这一切都工作得很好。添加更多核心,每个都有自己的缓存,我们就有了一个问题:如果其他某个核心修改了我们缓存中的数据,会发生什么?
嗯,答案很简单:什么也不会发生。这很糟糕,因为我们希望当其他人修改我们拥有缓存副本的内存时,有些事情应该发生。一旦我们有了多个缓存,我们真的需要保持它们同步,否则我们并没有真正的"共享内存"系统,更像是"共享内存大致内容"的系统。
请注意,问题确实在于我们有多个缓存,而不是我们有多个核心。我们可以通过在所有核心之间共享所有缓存来解决整个问题:只有一个L1\(,所有处理器都必须共享它。每个周期,L1\)选择一个幸运的核心,让它在这个周期执行内存操作,并运行它。
这工作得很好。唯一的问题是它也很慢,因为核心现在大部分时间都在排队等待下一次L1$请求(处理器会做很多这样的操作,至少每个加载/存储指令一次)。我指出这一点是因为它表明问题实际上不完全是多核问题,而是多缓存问题。我们知道一组缓存是有效的,但当那太慢时,次优的选择是拥有多个缓存,然后让它们表现得好像只有一个缓存。这就是缓存一致性协议的用途:正如其名,它们确保多个缓存的内容保持一致。
有多种类型的一致性协议,但您日常处理的大多数计算设备都属于"侦听"协议的范畴,这就是我将要介绍的内容。(主要的替代方案,基于目录的系统,具有更高的延迟但能更好地扩展到具有许多核心的系统。)
👂 侦听协议¶¶
侦听背后的基本思想是,所有内存事务都发生在所有核心可见的共享总线上:缓存本身是独立的,但内存本身是共享资源,内存访问需要仲裁:在任何给定周期,只有一个缓存可以从内存读取数据或将数据写回内存。现在,侦听协议的想法是,缓存不仅在自己想要执行内存事务时才与总线交互;相反,每个缓存持续侦听总线流量,以跟踪其他缓存正在做什么。因此,如果一个缓存想要代表其核心从内存读取或写入内存,所有其他核心都会注意到,这使它们能够保持缓存同步。一旦一个核心写入内存位置,其他核心就知道它们对应的缓存行副本现在已过时,因此无效。
对于写通过缓存,这相当简单,因为写入在发生时就被"发布"了。但如果涉及写回缓存,这就不起作用了,因为物理写回内存可能发生在核心执行相应存储很长时间之后——在这段时间内,其他核心及其缓存一无所知,并且可能自己尝试写入同一位置,导致冲突。因此,在写回模型中,仅仅在写入发生时广播写入内存是不够的;如果我们想避免冲突,我们需要在开始更改本地副本中的任何内容之前,告诉其他核心我们打算写入。研究细节后,满足要求并适用于写回缓存的最简单解决方案通常称为MESI协议。
🔢 MESI及其变体¶¶
本节称为"MESI及其变体",因为MESI衍生出了一系列密切相关的一致性协议。让我们从原始版本开始:MESI是多核系统中缓存行可以处于的四种状态的首字母缩写。我将以相反的顺序介绍它们,因为这是更好的解释顺序:
-
无效(Invalid) 行是那些要么不在缓存中,要么其内容已知已过时的缓存行。就缓存而言,这些被忽略。一旦缓存行被无效,就好像它一开始就不在缓存中一样。
-
共享(Shared) 行是主内存内容的干净副本。处于共享状态的缓存行可用于服务读取,但不能写入。允许多个缓存同时拥有同一内存位置的"共享"状态副本,因此得名。
-
独占(Exclusive) 行也是主内存内容的干净副本,就像S状态一样。区别在于,当一个核心持有处于E状态的行时,其他核心可能同时持有它,因此是"独占的"。也就是说,同一行在所有其他核心的缓存中必须处于I状态。
-
修改(Modified) 行是脏的;它们已被局部修改。如果一行处于M状态,它必须在所有其他核心中处于I状态,与E相同。此外,修改的缓存行在被逐出或无效时需要写回内存——与写回缓存中的常规脏状态相同。
如果您将其与上述单核情况下的写回缓存表示进行比较,您会看到I、S和M状态已经有它们的等价物:无效/不存在、干净和脏缓存行。那么新的是E状态,表示独占访问。这个状态解决了"我们需要在开始修改内存之前告诉其他核心"的问题:每个核心只能写入其缓存以E或M状态持有的缓存行,即它们独占拥有。如果核心在想要写入时没有对缓存行的独占访问权,它首先需要向总线发送"我想要独占访问"的请求。这告诉所有其他核心使该缓存行的副本无效(如果它们有的话)。只有在授予独占访问权后,核心才能开始修改数据——此时,核心知道该缓存行的唯一副本在其自己的缓存中,因此不可能有任何冲突。
相反,一旦其他某个核心想要读取该缓存行(我们立即知道这一点,因为我们在侦听总线),独占和修改的缓存行必须恢复为"共享"(S)状态。对于修改的缓存行,这还涉及首先将其数据写回内存。
MESI协议是一个适当的状态机,既响应来自本地核心的请求,也响应总线上的消息。我不会详细讨论完整的状态图以及不同的转换类型是什么;如果您关心,可以在硬件架构的书籍中找到更深入的信息,但就我们的目的而言,这有些过度了。作为软件开发人员,您只需要知道两件事就能走得很远:
首先,在多核系统中,获取对缓存行的读取访问权涉及与其他核心通信,并可能导致它们执行内存事务。
写入缓存行是一个多步骤过程:在写入任何内容之前,您首先需要获取缓存行的独占所有权及其现有内容的副本(所谓的"读取所有权"请求)。
其次,虽然我们必须做一些额外的操作,但最终结果实际上提供了一些相当强的保证。即,它遵守我将称之为MESI不变式的原则:
MESI不变式:在写回所有脏(M状态)缓存行之后,在任何缓存级别中存在的所有缓存行的内容都与相应地址的内存值相同。此外,在任何时候,当内存位置被一个核心独占缓存(处于E或M状态)时,它不存在于任何其他核心的缓存中。
请注意,这实际上只是我们已经看到的写回不变式,加上额外的独占性规则。我的观点是,MESI或多个核心的存在并不一定会削弱我们的内存模型。
好的,那么这(非常粗略地)涵盖了普通的MESI(以及因此使用它的CPU,例如ARM)。其他处理器使用扩展变体。流行的扩展包括类似于"E"的"O"(拥有)状态,允许共享脏缓存行而不必先将其写回内存("脏共享"),产生MOESI,以及MERSI/MESIF,它们是同一想法的不同名称,即指定一个核心作为给定缓存行读取请求的指定响应者。当多个核心以共享状态持有缓存行时,只有指定响应者(以"R"或"F"状态持有缓存行)回复读取请求,而不是每个以S状态持有缓存行的人都回复。这减少了总线流量。当然,您可以同时添加R/F状态和O状态,或者变得更花哨。所有这些都是优化,但它们都没有改变协议提供的基本不变式或保证。
我不是这个主题的专家,很可能存在其他仅提供实质上较弱保证的协议,但如果是这样,我不知道它们,也不知道任何使用它们的流行CPU核心。因此,就我们的目的而言,我们真的可以假设一致性协议保持缓存一致,就这样。不是大部分一致,不是"除了更改后的短暂窗口外一致"——而是完全一致。在那个层面上,除非硬件故障,否则对于内存的当前状态总是有共识的。用技术术语来说,MESI及其所有变体原则上可以提供完整的顺序一致性,这是C++11内存模型中规定的最强内存排序保证。这就引出了一个问题,为什么我们会有较弱的内存模型,以及"它们在哪里发生"?
🧩 内存模型¶¶
不同的架构提供不同的内存模型。截至本文撰写时,ARM和POWER架构机器具有相对"弱"的内存模型:CPU核心在重新排序加载和存储操作方面有相当大的自由度,可能会在多核上下文中改变程序的语义,以及"内存屏障"指令,程序可以使用这些指令来指定约束:"不要跨过这条线重新排序内存操作"。相比之下,x86具有相当强的内存模型。
我不会在这里深入讨论内存模型的细节;它很快变得非常技术性,并且超出了本文的范围。但我确实想谈谈"它们如何发生"——也就是说,与我们可以从MESI等获得的完整顺序一致性相比,较弱的保证来自哪里,以及为什么。和往常一样,这一切都归结为性能。
那么,情况是这样的:如果a)缓存在其接收到总线事件的同一周期立即响应总线事件,并且b)核心忠实地按程序顺序将每个内存操作发送到缓存,并在发送下一个操作之前等待它完成,那么您确实会获得完整的顺序一致性。当然,在实践中,现代CPU通常不做这些事情:
-
缓存不会立即响应总线事件。如果触发缓存行无效的总线消息在缓存忙于做其他事情(例如向核心发送数据)时到达,它可能不会在该周期被处理。相反,它将进入所谓的"无效队列",在那里停留一段时间,直到缓存有时间处理它。
-
核心通常不会按严格的程序顺序将内存操作发送到缓存;对于具有乱序执行的核心来说肯定是这样,但即使是其他顺序核心也可能对内存操作有较弱的排序保证(例如,确保单个缓存未命中不会立即使整个核心陷入停顿)。
-
特别是,存储是特殊的,因为它们是两阶段操作:在存储可以通过之前,我们首先需要获取缓存行的独占所有权。如果我们还没有独占所有权,我们需要与其他核心通信,这需要一段时间。同样,让核心闲置并在此期间无所事事并不是对执行资源的良好利用。相反,发生的情况是,存储开始获取独占所有权的进程,然后进入所谓的"存储缓冲区"队列(有些人将整个队列称为"存储缓冲区",但我将使用这个术语来指代条目)。它们在这个队列中停留一段时间,直到缓存准备好实际执行存储操作,此时相应的存储缓冲区被"排空"并可以回收以容纳新的待处理存储。
所有这些的含义是,默认情况下,加载可以获取过时数据(如果相应的无效请求坐在无效队列中),存储实际上完成得比代码中的位置所暗示的要晚,并且当乱序执行涉及其中时,一切变得更加模糊。因此,回到内存模型,基本上有两个阵营:
具有弱内存模型的架构在核心中做最少的工作,允许软件开发人员编写正确的代码。指令重新排序和各种缓冲阶段是正式允许的;没有保证。如果您需要保证,您需要插入适当的内存屏障——这将防止重新排序并在需要时排空待处理操作的队列。
具有较强内存模型的架构在内部做更多的簿记工作。例如,x86处理器跟踪所有尚未完全完成("退休")的待处理内存操作,在一个称为MOB("内存排序缓冲区")的芯片内部数据结构中。作为乱序基础设施的一部分,x86核心可以回滚未退休的操作,如果有问题——比如页面错误之类的异常,或者分支预测错误。我在早期的文章"推测性地说"中介绍了一些细节,以及与内存子系统的一些交互。要点是,x86处理器主动监视外部事件(如缓存无效),这些事件会追溯性地使一些已经执行但尚未退休的操作的结果无效。也就是说,x86处理器知道它们的内存模型是什么,当发生与该模型不一致的事件时,机器状态会回滚到最后一次仍然符合内存模型规则的时间。这就是我在另一篇早期文章中介绍的"内存排序机器清除"。最终结果是,x86处理器为所有内存操作提供了非常强的保证——虽然不是完全的顺序一致性。
因此,较弱的内存模型使核心更简单(并且可能功耗更低)。较强的内存模型使核心(及其内存子系统)的设计更复杂,但更容易编写代码。理论上,较弱的模型允许更多的调度自由,并且可能更快;实际上,就目前而言,x86似乎在内存操作的性能方面表现良好。因此,对我来说,很难确定一个明确的赢家。当然,作为软件开发人员,当我能获得较强的x86内存模型时,我很乐意接受它。
无论如何。这篇文章的内容已经足够了。既然我已经在我的博客上写下了所有这些,未来的文章就可以直接引用它了。我们看看这会如何发展。感谢阅读!
🎯 关键要点¶¶
-
缓存一致性是多缓存问题,而不仅仅是多核问题:即使只有一个核心,如果有多个缓存级别,也需要一致性机制。
-
MESI协议是基础:了解Modified、Exclusive、Shared、Invalid四种状态是理解现代CPU缓存一致性的关键。
-
写入需要独占所有权:在多核系统中,写入缓存行需要先获取独占访问权,这涉及与其他核心的通信。
-
内存模型是软件与硬件的契约:不同的架构提供不同强度的内存排序保证,软件开发者需要了解目标平台的内存模型。
-
性能与简单性的权衡:较弱的内存模型允许硬件优化,但增加了软件复杂性;较强的内存模型简化了编程,但限制了硬件优化。
💡 实际应用建议¶¶
对于软件开发人员,以下是一些实用建议:
1. 了解目标平台¶
- 如果您主要针对x86,可以依赖较强的内存排序保证
- 如果针对ARM或其他弱内存模型架构,必须使用适当的内存屏障
- 跨平台代码应该使用标准并发原语(如C++11的
std::atomic)
2. 编写缓存友好的代码¶
- 尽量减少对共享数据的写入
- 使用线程本地存储避免缓存一致性开销
- 将经常一起访问的数据放在一起(空间局部性)
3. 使用正确的同步原语¶
- 对于简单的标志,使用原子操作而不是锁
- 了解不同同步原语的内存排序语义
- 避免过度同步,只在必要时使用内存屏障
4. 性能分析¶
- 使用性能分析工具检测缓存一致性开销
- 注意"虚假共享"问题(多个核心频繁写入同一缓存行的不同部分)
- 考虑数据布局对缓存性能的影响
⚠️ 常见误区¶¶
-
"volatile关键字保证多线程安全":错误。
volatile不提供内存排序保证,只防止编译器优化。 -
"单核系统不需要考虑内存排序":部分正确。单核系统确实有较简单的内存模型,但现代CPU的乱序执行仍然可能重新排序内存操作。
-
"缓存一致性是免费的":错误。保持缓存一致需要硬件开销,包括总线流量、无效队列管理等。
-
"所有架构的内存模型都类似":错误。不同架构(x86、ARM、PowerPC)有显著不同的内存模型。
📚 延伸阅读¶
- 为什么CPU有多级缓存? - 理解缓存层次结构的基础
- 数据流图快速入门 - 了解缓存延迟对性能的影响
- 《C++并发编程实战》 - 深入理解内存模型和并发编程
- 《计算机体系结构:量化方法》 - 关于缓存一致性和内存系统的经典教科书
💭 讨论问题¶
- 在什么情况下,缓存一致性协议可能成为性能瓶颈?
- 如何设计数据结构以最小化缓存一致性开销?
- 非一致性内存访问(NUMA)系统与一致性内存访问(UMA)系统有何不同?
- 未来可能出现哪些新的缓存一致性协议?
翻译说明:本文翻译自Fabian Giesen的Cache coherency primer,在保持技术准确性的前提下进行了适当简化和重组,以增强中文可读性。
版权声明:原文版权归Fabian Giesen所有。翻译内容仅供学习和参考使用。