本章深入探讨内存一致性模型(也称为内存模型),该模型为程序员和实现者定义了共享内存系统的行为。这些模型定义了正确性,以便程序员知道可以期望什么,实现者知道需要提供什么。我们首先阐述定义内存行为的必要性(3.1 节),说明内存一致性模型应实现的功能(3.2 节),并比较和对比内存一致性与连贯性(3.3 节)。
然后,我们探索顺序内存一致性(SC)这一(相对)直观的模型。顺序内存一致性很重要,因为它是许多程序员对共享内存的期望,并为理解接下来两章中介绍的更宽松(弱)的内存一致性模型奠定了基础。我们首先介绍顺序内存一致性的基本概念(3.4 节),并给出其形式化定义,该定义也将在后续章节中使用(3.5 节)。接着,我们讨论顺序内存一致性的实现,从作为操作模型的简单实现开始(3.6 节),接着是具有缓存一致性的顺序内存一致性的基本实现(3.7 节)、更优化的具有缓存一致性的顺序内存一致性实现(3.8 节),以及原子操作的实现(3.9 节)。通过提供 MIPS R10000 的案例研究来结束对顺序内存一致性的讨论(3.1 节),并指出一些进一步阅读的资料(3.11 节)。
3.1 共享内存行为存在的问题
为了理解为什么必须定义共享内存行为,考虑表 3.1 中描述的两个核心的示例执行情况。(与本章中的所有示例一样,此示例假设所有变量的初始值为零。)大多数程序员会期望核心 C2 的寄存器 r2 应该获取值 NEW。然而,在当今的某些计算机系统中,r2 可能为 0。
硬件可以通过重新排序核心 C1 的存储操作 S1 和 S2 使 r2 获取值 0。在本地(即,如果我们只看 C1 的执行情况,而不考虑与其他线程的交互),这种重新排序似乎是正确的,因为 S1 和 S2 访问不同的地址。下方页面的侧边栏小节,描述了硬件可能对内存访问进行重新排序的几种方式,包括这些存储操作。不是硬件专家的读者可以相信这种重新排序是可能发生的(例如,使用非先进先出的写缓冲区)。

随着 S1 和 S2 的重新排序,执行顺序可能是 S2、L1、L2、S1,如表 3.2 所示。
侧边栏:核心如何对内存访问进行重新排序
此侧边栏描述了现代核心可能对不同地址的内存访问进行重新排序的几种方式。不熟悉这些硬件概念的读者可以在第一次阅读时跳过此部分。现代核心可能会对许多内存访问进行重新排序,但仅考虑对两个内存操作进行重新排序就足够了。在大多数情况下,我们只需要考虑核心对两个不同地址的内存操作进行重新排序,因为顺序执行(即冯・诺依曼)模型通常要求对相同地址的操作按原始程序顺序执行。我们根据重新排序的内存操作是加载还是存储,将可能的重新排序分为三种情况。
存储 - 存储重新排序 如果核心有一个非先进先出的写缓冲区,允许存储操作以与它们进入的顺序不同的顺序离开,那么两个存储操作可能会被重新排序。如果第一个存储操作在缓存中未命中,而第二个存储操作命中,或者如果第二个存储操作可以与更早的存储操作合并(即在第一个存储操作之前),就可能发生这种情况。请注意,即使核心按程序顺序执行所有指令,这些重新排序也是可能的。对不同内存地址的存储操作进行重新排序对单线程执行没有影响。然而,在表 3.1 的多线程示例中,重新排序核心 C1 的存储操作允许核心 C2 在看到对数据的存储操作之前看到标志为 SET。请注意,即使写缓冲区排入到完全一致的内存层次结构中,这个问题也无法解决。缓存一致性会使所有缓存不可见,但存储操作已经被重新排序了。【注,强调了缓存一致性,与 顺序内存一致性模型,要解决的是互相独立的两个任务。】
加载 - 加载重新排序 现代动态调度核心可能会以程序顺序之外的顺序执行指令【注,罗列所有可能引起顺序变化的因素】。在表 3.1 的示例中,核心 C2 可以以乱序执行加载操作 L1 和 L2。仅考虑单线程执行,这种重新排序似乎是安全的,因为 L1 和 L2 访问不同的地址。然而,重新排序核心 C2 的加载操作与重新排序核心 C1 的存储操作效果相同;如果内存引用以 L2、S1、S2 和 L1 的顺序执行,那么 r2 将被赋值为 0。如果省略分支语句 B1,这种情况更有可能发生,因为没有控制依赖关系分隔 L1 和 L2。
加载 - 存储和存储 - 加载重新排序 乱序核心也可能对来自同一线程的加载和存储操作(到不同地址)进行重新排序。将较早的加载操作与较晚的存储操作重新排序(加载 - 存储重新排序)可能会导致许多不正确的行为,例如在释放保护某个值的锁之后加载该值(如果存储操作是解锁操作)【注,对锁的实现是靠着原子操作加锁的;解锁时,依靠防乱序store 指令,以及防编译器重排的内存屏障。还有保证全局数据一致的coherence 协议】。表 3.3 中的示例说明了将较早的存储操作与较晚的加载操作重新排序(存储 - 加载重新排序)的效果。重新排序核心 C1 的访问操作 S1 和 L1 以及核心 C2 的访问操作 S2 和 L2 会导致一个违反直觉的结果,即 r1 和 r2 都为 0。请注意,即使核心按程序顺序执行所有指令,由于在常用的先进先出写缓冲区中进行本地旁路,存储 - 加载重新排序也可能发生。【注,上文提到的"本地旁路"(Local Bypassing)是指现代处理器中写缓冲区(Write Buffer/Store Buffer)的一种优化机制,它允许CPU核心直接从写缓冲区读取尚未提交到缓存/内存的写操作结果,而不必等待写操作真正完成。这是导致存储-加载(Store-Load)重排序的关键原因之一。这种优化称为"存储转发"(Store Forwarding)】
读者可能会认为硬件不应该允许其中一些或所有这些行为,但如果没有更好地理解哪些行为是被允许的,就很难确定硬件能做什么和不能做什么。

这种执行满足缓存一致性,因为没有违反单写多读(SWMR)属性,所以不一致性不是这种看似错误的执行结果的根本原因。
让我们考虑另一个受 Dekker 算法启发的重要示例,该算法用于确保互斥,如表 3.3 所示。执行后,r1 和 r2 中允许出现哪些值呢?直观地说,可能会有三种可能性:
对于执行 S1、L1、S2,然后是 L2,(r1, r2) = (0, NEW)。
对于 S2、L2、S1 和 L1,(r1, r2) = (NEW, 0)。
对于 S1、S2、L1 和 L2,(r1, r2) = (NEW, NEW)。
令人惊讶的是,大多数实际硬件,例如英特尔和 AMD 的 x86 系统,也允许 (r1, r2) = (0, 0),因为它们使用先进先出(FIFO)写缓冲区来提高性能。与表 3.1 中的示例一样,所有这些执行都满足缓存一致性,即使是 (r1, r2) = (0, 0) 的情况。
一些读者可能会反对这个示例,因为它是非确定性的(允许多个结果),并且可能是一个令人困惑的编程习惯用法。然而,首先,所有当前的多处理器默认情况下都是非确定性的;据我们所知,所有架构都允许并发线程的执行有多种可能的交错方式。确定性的错觉有时是由使用适当同步习惯用法的软件造成的,但并非总是如此。因此,在定义共享内存行为时,我们必须考虑非确定性。
此外,内存行为通常是为所有程序的所有执行定义的,即使是那些不正确或故意设计得很微妙的程序(例如,对于非阻塞同步算法)。然而,在第 5 章中,我们将看到一些高级语言模型,这些模型允许某些执行具有未定义的行为【注,本从句不知道如何翻译会更顺畅:that allow some
executions to have undefined behavior,】,例如具有数据竞争的程序的执行。
3.2 什么是内存一致性模型?
上一小节中的示例说明了共享内存行为很微妙,这凸显了精确定义以下两点的重要性:(a) 程序员可以期望的行为;(b) 系统实现者可以使用的优化。内存一致性模型消除了这些问题的歧义。
内存一致性模型,或更简单地说,内存模型,是对使用共享内存执行的多线程程序的允许行为的规范。对于使用特定输入数据执行的多线程程序,它指定了动态加载操作可能返回的值。与单线程执行不同,多线程执行通常允许多种正确的行为。

一般来说,内存一致性模型 MC 给出了一些规则,这些规则将执行分为符合 MC 的执行(MC 执行)和不符合 MC 的执行(非 MC 执行)。这种对执行的划分反过来又对实现进行了划分。符合 MC 的实现是指只允许 MC 执行的系统,而非 MC 实现有时会允许非 MC 执行。
最后,我们在编程级别上一直说得比较模糊。我们首先假设程序是硬件指令集架构中的可执行文件,并且我们假设内存访问是对由物理地址标识的内存位置进行的(即,我们不考虑虚拟内存和地址转换的影响)。在第 5 章中,我们将讨论高级语言(HLL)的相关问题。到那时我们会看到,例如,编译器将变量分配到寄存器中可能会以类似于硬件重新排序内存引用的方式影响高级语言的内存模型。
3.3 一致性与连贯性的对比
第 2 章用两个不变量定义了缓存一致性,我们在此非正式地重复一下。单写多读(SWMR)不变量确保在任何时候,对于具有给定地址的内存位置,要么 (a) 一个核心可以写入(和读取)该地址,要么 (b) 零个或多个核心只能读取该地址。数据值不变量确保对内存位置的更新能够正确传递,以便内存位置的缓存副本始终包含最新版本。
看起来缓存一致性似乎定义了共享内存行为,但事实并非如此。从图 3.1 中可以看出,缓存一致性协议只是为处理器核心流水线提供了内存系统的抽象。仅靠它无法确定共享内存行为;流水线也很重要。例如,如果流水线以与程序顺序相反的顺序重新排序内存操作,并将其呈现给缓存一致性协议 —— 即使缓存一致性协议正确地完成了它的工作 —— 共享内存的正确性也可能无法保证。
总结如下:
缓存一致性不等于内存一致性。
内存一致性实现可以将缓存一致性用作有用的 “黑盒”。
3.4 顺序一致性(SC)的基本思想
可以说,最直观的内存一致性模型是顺序一致性(SC)。它最早由 Lamport 形式化定义 [12],他称单个处理器(核心)是顺序的,如果 “一个执行的结果与操作按程序指定的顺序执行时的结果相同”【注:这种单核处理器体现了程序顺序,这里隐含的意思是单core时,允许core 中存在乱序执行优化功能。给定时的单核程序只能指定一种程序顺序,乱序执行且结果完全相同的新序假设有N种,通常N>1,N 种乱序执行后得到结果总是全部相同。结果不同的,是另一种不同的程序顺序,也是错误的模型,现不予关注】。然后他称多处理器是顺序一致的,如果 “任何执行的结果都与所有处理器(核心)的操作按某种顺序执行时的结果相同,并且每个单独处理器(核心)的操作在该顺序中按照其程序指定的顺序出现”【注:这种多核处理器体现了程序的一种全序的顺序。比喻:鸽尾式洗牌,双手持牌,拇指分别拨动两叠牌的边缘,使牌交错落下合并,形成交替穿插的效果。说明:这里隐含的意思是说,这里存在着全部操作的全局顺序不止一种,这是显然的,假如是 N 种:N = n1 + n2 + ... + nx;其中,ni 中的全部 ni 种可能的全部操作的全局顺序的执行结果完全相同。另外nj 中的执行结果完全相同。如果多处理器时,程序执行总是落在 nk 中的某一种全局序,那么多处理器就是顺序一致的,同一批代码的执行结构总是相同】。这种操作的全序称为内存顺序。在顺序一致性中,内存顺序尊重每个核心的程序顺序,但其他一致性模型可能允许不总是尊重程序顺序的内存顺序。

图 3.2 描绘了表 3.1 中示例程序的执行情况。中间向下的垂直箭头表示内存顺序(<m),而每个核心向下的箭头表示其程序顺序(<p)。我们使用操作符 <m 表示内存顺序,因此 op1 <m op2 表示 op1 在内存顺序中先于 op2。类似地,我们使用操作符 <p 表示给定核心的程序顺序,所以 op1 <p op2 表示 op1 在该核心的程序顺序中先于 op2。在顺序一致性下,内存顺序尊重每个核心的程序顺序。“尊重” 意味着 op1 <p op2 意味着 op1 <m op2【注:原左手中的上下牌,在鸽尾式洗牌后依然保证上下关系。】。注释(/*... */)中的值给出了加载或存储的值。此执行以 r2 为 NEW 结束。更一般地说,表 3.1 中程序的所有执行都以 r2 为 NEW 结束。唯一的非确定性 —— 在 L1 加载值 SET 之前,L1 将标志加载为 0 的次数 —— 并不重要。
这个示例说明了顺序一致性的价值。在 3.1 节中,如果你期望 r2 必须是 NEW,那么你可能在独立地构思顺序一致性,尽管没有 Lamport 那么精确。
顺序一致性的价值在下方 图 3.3 中进一步体现,该图展示了表 3.3 中程序的四种执行情况。图 3.3a-c 描绘了与三种直观输出相对应的顺序一致性执行:(r1, r2) = (0, NEW)、(NEW, 0) 或 (NEW, NEW)。请注意,图 3.3c 仅描绘了导致 (r1, r2) = (NEW, NEW) 的四种可能的顺序一致性执行中的一种;此执行是 {S1, S2, L1, L2},其他的是 {S1, S2, L2, L1}、{S2, S1, L1, L2} 和 {S2, S1, L2, L1}。因此,在图 3.3a-c 中,有六种合法的顺序一致性执行。
图 3.3d 展示了与输出 (r1, r2) = (0, 0) 对应的非顺序一致性执行。对于此输出,无法创建一个尊重程序顺序的内存顺序。程序顺序规定:
S1 <p L1
S2 <p L2
但内存顺序规定:
L1 <m S2(所以 r1 为 0)
L2 <m S1(所以 r2 为 0)
满足所有这些约束会导致一个循环【注,即出现了顺序矛盾】,这与全序不一致。图 3.3d 中额外的弧线展示了这个循环。
我们刚刚看到了六种顺序一致性执行和一种非顺序一致性执行。这有助于我们理解顺序一致性的实现:顺序一致性实现必须允许前六种执行中的一种或多种,但不能允许第七种执行。
3.5 顺序一致性的形式化描述
在本节中,我们将更精确地定义顺序一致性(SC),特别是为了能在接下来的两章中,将顺序一致性与弱一致性模型进行比较。我们采用了韦弗(Weaver)和热尔蒙(Germond)[20] 的形式化方法 —— 这是一种用于指定内存一致性的公理化方法,我们将在第 11 章中对其进行更深入的讨论 —— 使用以下表示法:L(a) 和 S(a) 分别表示对地址 a 的加载和存储操作。顺序 <p 和 <m 分别定义了程序顺序和全局内存顺序。程序顺序 <p 是每个核心的全序,它体现了每个核心在逻辑上(按顺序)执行内存操作的顺序。全局内存顺序 <m 是所有核心的内存操作的全序。

一个符合顺序一致性的执行需要满足以下条件:
(1. 保序)所有核心将它们的加载和存储操作插入到内存顺序 <m 中时,要遵循它们的程序顺序,无论这些操作是针对相同地址还是不同地址(即 a = b 或 a ≠ b)。这里有四种情况:
如果 L(a) <p L(b) ,那么 L(a) <m L(b) (加载 - 加载情况)
如果 L(a) <p S(b) ,那么 L(a) <m S(b) (加载 - 存储情况)
如果 S(a) <p S(b) ,那么 S(a) <m S(b) (存储 - 存储情况)
如果 S(a) <p L(b) ,那么 S(a) <m L(b) (存储 - 加载情况)
(2. 最新)每个加载操作从在全局内存顺序中位于它之前的对同一地址的最后一次存储操作中获取值:
的值 =
的值,其中
表示 “在内存顺序中最新的”。
原子读改写 read-modify-write(RMW)指令(我们将在 3.9 节中更深入地讨论)进一步限制了允许的执行情况。例如,test-and-set 指令的每次执行都要求 测试的加载操作和设置的存储操作 在逻辑上在内存顺序中连续出现(即,对于相同或不同地址的其他内存操作不能插入在它们之间)。
我们在表 3.4 中总结了顺序一致性的顺序要求。该表指定了内存一致性模型所强制实施的程序顺序。例如,如果在程序顺序中给定线程的加载操作在存储操作之前(即,加载操作是表中的 “操作 1”,存储操作是 “操作 2”),那么在这个交叉点的表项是一个 “X”,这表示这些操作必须按照程序顺序执行。对于顺序一致性,所有内存操作必须看起来是按照程序顺序执行的;在接下来两章中我们将研究的其他一致性模型下,这些顺序约束中的一些会被放宽(即,它们的顺序表中的一些表项不包含 “X”)。
一个符合顺序一致性的实现只允许符合顺序一致性的执行。严格来说,这是顺序一致性实现的安全属性(不做坏事儿)。顺序一致性实现还应该具有一些活性属性(做一些好事儿)。具体来说,对于反复尝试加载某个位置的加载操作来说,一个存储操作最终必须是可见的。这个属性被称为最终写传播,通常由缓存一致性协议来保证。更一般地说,避免饥饿和一定程度的公平性也很重要,但这些问题超出了我们当前讨论的范围。

3.6 简单的顺序一致性实现
顺序一致性有两种简单的实现方式,这有助于理解顺序一致性允许哪些执行情况。【注,作者提供了构造法来描述顺序一致性】
多任务单处理器
首先,可以通过在单个顺序核心(单处理器)上执行所有线程,来为多线程用户级软件实现顺序一致性。线程 T1 的指令在核心 C1 上执行,直到发生上下文切换到线程 T2 等等。在上下文切换时,在切换到新线程之前,所有未完成的内存操作必须完成。因为每个线程在其时间片内的指令作为一个原子块执行(并且因为单处理器正确地遵循内存依赖关系),所以所有顺序一致性规则都得到了执行。
切换器方式
其次,可以使用一组核心 C、一个切换器和内存来实现顺序一致性,如图 3.4 所示。假设每个核心按照其程序顺序一次向切换器提交一个内存操作。每个核心可以使用任何不会影响其向切换器提交内存操作顺序的优化。例如,可以使用带有分支预测的简单五级顺序流水线。接下来假设切换器选择一个核心,允许内存完全满足加载或存储操作,并且只要存在请求就重复这个过程。切换器可以通过任何方法(例如随机)选择核心,只要不会使有就绪请求的核心处于饥饿状态。这种实现从操作层面上实现了顺序一致性。

评估
这些实现的好处是,它们提供了操作模型,用于定义(1)允许的顺序一致性执行情况,以及(2)顺序一致性实现的 “黄金标准”。(在第 11 章中,我们将看到这样的操作模型可以用于正式指定内存一致性模型。)切换器实现还作为一个存在性证明,即顺序一致性可以在没有缓存或缓存一致性机制的情况下实现。
当然,不好的消息是,由于第一种情况中使用单个核心的顺序瓶颈,以及第二种情况中单个切换器 / 内存的顺序瓶颈,这些实现的性能无法随着核心数量的增加而扩展。这些瓶颈导致一些人错误地得出结论,认为顺序一致性排除了真正的并行执行。但事实并非如此,我们接下来会看到。
3.7 具有缓存一致性的基本顺序一致性实现
缓存一致性有助于实现顺序一致性,使得非冲突的加载和存储操作(何为冲突的:如果两个操作针对相同地址,并且至少其中一个是存储操作,那么这两个操作就是冲突的)能够完全并行执行。此外,从概念上讲,创建这样一个系统很简单。
在这里,我们将一致性机制大致看作一个黑盒,它实现了第 2 章中的单写多读(SWMR)不变性。我们稍微打开一致性模块这个黑盒,揭示一些简单的一级(L1)缓存的情况,以此提供一些实现上的直观理解:
使用修改(M)状态来指示一个 L1 缓存块,表示一个核心可以对其进行读写操作。
使用共享(S)状态来指示一个 L1 缓存块,表示一个或多个核心只能对其进行读取操作。
使用 GetM 和 GetS 分别表示获取处于 M 状态和 S 状态的缓存块的一致性请求。
我们不需要深入理解一致性是如何实现的,这在第 6 章及后续章节中会讨论。
图 3.5a 描述了图 3.4 的模型,其中切换器和内存被一个表示为黑盒的缓存一致性内存系统所取代。每个核心按照其程序顺序一次向缓存一致性内存系统提交一个内存操作。内存系统在开始处理同一个核心的下一个请求之前,会完全满足每个请求。
图 3.5b 稍微 “打开” 了内存系统这个黑盒,展示了每个核心连接到其自己的 L1 缓存(我们稍后会讨论多线程情况)。如果内存系统拥有具有适当的缓存一致性权限的缓存块 B(对于加载操作,块的状态为 M 或 S;对于存储操作,块的状态为 M),那么它可以响应针对缓存块 B 的加载或存储操作。此外,只要相应的 L1 缓存具有适当的权限,内存系统可以并行响应来自不同核心的请求。

例如,图 3.6a 描述了四个核心各自尝试进行内存操作之前的缓存状态。这四个操作不冲突,可以由它们各自的 L1 缓存满足,因此可以并发执行。如图 3.6b 所示,我们可以任意排列这些操作的顺序,以获得合法的顺序一致性执行模型。更一般地说,可以由 L1 缓存满足的操作总是可以并发执行,因为缓存一致性的 SWMR 不变性确保它们不会冲突。
【 state S,表示共享 shared; 】

3.8 具有缓存一致性的优化的顺序一致性实现
大多数实际的核心实现要比我们基于缓存一致性的基本顺序一致性实现更为复杂。为了提高性能并容忍【注,隐藏之意】内存访问延迟,核心采用了诸如预取、推测执行和多线程等特性。这些特性与内存接口相互作用,接下来我们将探讨它们对顺序一致性实现的影响。值得记住的是,只要某种特性或优化不会产生违反顺序一致性的最终结果(加载操作返回的值),那么它就是合法的。
非绑定预取
对块 B 的非绑定预取是向一致性内存系统发出的请求,目的是改变一个或多个块 B 的一致性状态。最常见的情况是,软件、核心硬件或缓存硬件会发出预取请求,通过发出诸如 GetS 和 GetM 之类的一致性请求,将一级缓存中块 B 的状态改变为允许加载(例如,块 B 的状态为 M 或 S)或者允许加载和存储(块 B 的状态为 M)。重要的是,非绑定预取绝不会改变寄存器的状态或块 B 中的数据。非绑定预取的影响仅限于图 3.5a 中的 “缓存一致性内存系统” 模块内,这使得非绑定预取对内存一致性模型的影响在功能上等同于无操作。只要加载和存储操作按照程序顺序执行,获取一致性权限的顺序就无关紧要。
实现可以进行非绑定预取而不影响内存一致性模型。这对于内部缓存预取(例如,流缓冲区)和更激进的核心都很有用。
推测执行核心
假设有一个核心按程序顺序执行指令,但也进行分支预测。在这种情况下,包括加载和存储在内的后续指令会开始执行,但如果分支预测错误,这些指令可能会被取消(即,其效果会被消除)。可以让这些被取消的加载和存储操作看起来像非绑定预取,这样这种推测执行就是正确的,因为它对顺序一致性没有影响。分支预测后的加载操作可以被发送到一级缓存,如果未命中(会导致一个非绑定的 GetS 预取),或者命中后将值返回给寄存器。如果加载操作被取消,核心会丢弃对寄存器的更新,消除加载操作的任何功能影响 —— 就好像它从未发生过一样。缓存不会撤销非绑定预取,因为这样做没有必要,而且如果加载操作重新执行,预取该块有助于提高性能。对于存储操作,核心可能会提前发出非绑定的 GetM 预取,但直到存储操作确定会提交时,才会将存储操作发送到缓存。
回顾测验问题 1:在一个保持顺序一致性的系统中,核心必须按程序顺序发出缓存一致性请求。这句话是对还是错?
答案:错误!核心可以按任意顺序发出缓存一致性请求。
动态调度核心
许多现代核心会动态地乱序调度指令执行,以比必须严格按程序顺序执行指令的静态调度核心获得更高的性能。使用动态或乱序调度的单核心处理器只需在程序内强制保证真正的数据依赖关系。然而,在多核处理器的环境中,动态调度引入了一个新问题:内存一致性推测。假设有一个核心想要动态地重新排序两个加载操作 L1 和 L2 的执行顺序(例如,因为 L2 的地址比 L1 的地址先计算出来)。许多核心会在 L1 之前推测性地执行 L2,并预测这种重新排序对其他核心是不可见的,否则就会违反顺序一致性。
对顺序一致性进行推测要求核心验证预测是否正确。加拉乔洛等人 [8] 提出了两种进行此检查的技术。第一种是,在核心推测性地执行 L2 之后,但在提交 L2 之前,核心可以检查推测性访问的块是否仍在缓存中。只要该块仍在缓存中,在加载操作执行和提交之间其值就不可能改变。为了进行此检查,核心会跟踪 L2 加载的地址,并将其与被逐出的块以及传入的缓存一致性请求进行比较。传入的 GetM 表示另一个核心可能会观察到 L2 乱序执行,这意味着推测错误,需要取消推测性执行。
第二种检查技术是,当核心准备提交加载操作时,重新执行每个推测性加载操作 [2, 17]。如果提交时加载的值不等于之前推测性加载的值,那么预测就是错误的。在上述示例中,如果重新执行的 L2 加载值与最初加载的 L2 值不同,那么加载 - 加载重新排序导致了可观察到的不同执行结果,必须取消推测性执行。
动态调度核心中的非绑定预取
动态调度核心可能会乱序遇到加载和存储缺失。例如,假设程序顺序是先加载 A,然后存储 B,最后存储 C。核心可能会 “乱序” 发起非绑定预取,例如,先发起对 C 的 GetM 预取,然后并行发起对 A 的 GetS 预取和对 B 的 GetM 预取。非绑定预取的顺序不会影响顺序一致性。顺序一致性只要求核心的加载和存储操作(看起来)按程序顺序访问其一级缓存。缓存一致性要求一级缓存块处于适当的状态以接收加载和存储操作。
重要的是,顺序一致性(或任何其他内存一致性模型):
规定了加载和存储操作(看起来)应用于缓存一致性内存的顺序;
但不规定缓存一致性活动的顺序。
回顾测验问题 2:内存一致性模型指定了缓存一致性事务的合法顺序。这句话是对还是错?
答案:错误!
多线程
顺序一致性实现可以支持粗粒度、细粒度或同时多线程。每个多线程核心在逻辑上应等同于多个(虚拟)核心,这些虚拟核心通过一个切换器共享每个一级缓存,缓存会选择接下来为哪个虚拟核心提供服务。此外,每个缓存实际上可以同时处理多个非冲突请求,因为它可以假定这些请求是按某种顺序处理的。一个挑战是要确保线程 T1 不能在另一个线程 T2 在同一核心上进行的存储操作对其他核心上的线程 “可见” 之前读取该存储的值。因此,虽然线程 T1 可以在线程 T2 将存储操作插入内存顺序(例如,将其写入处于 M 状态的缓存块)后立即读取该值,但它不能从处理器核心中的共享加载 - 存储队列中读取该值。
侧边栏:高级顺序一致性优化
本侧边栏介绍一些高级的顺序一致性优化方法。
退休后推测:单核心处理器通常会使用一种称为写(存储)缓冲区的结构来隐藏存储缺失的延迟;存储操作从处理器流水线退休到写缓冲区,然后从写缓冲区进入缓存 / 内存系统,这样就不会影响关键路径。在单核心处理器上,只要加载操作检查写缓冲区中是否有对同一地址的未完成存储操作,这种方法就是安全的。然而,在多核处理器上,顺序一致性的排序规则禁止简单地使用写缓冲区。动态调度核心可以隐藏部分但不是全部的存储缺失延迟。为了隐藏更多的存储缺失延迟,人们提出了许多积极实现顺序一致性的方案,利用超出指令窗口的推测机制。关键思想是在保持未完成存储缺失的同时,推测性地退休加载和存储操作,同时以细粒度 [9, 16] 或粗粒度块 [1, 3, 11, 19] 的方式分别维护推测性退休指令的状态。
非推测性重新排序:只要重新排序对其他核心不可见,甚至可以在强制保证顺序一致性的同时非推测性地乱序执行内存操作 [7, 18]。在不进行回滚恢复的情况下,如何确保重新排序对其他核心不可见呢?
一种方法(称为一致性延迟)涉及延迟一致性请求:具体来说,当一个较新的内存操作在一个未完成的较旧操作之后退休时,对较新操作所在位置的一致性请求会被延迟,直到较旧的内存操作退休。一致性延迟存在固有的死锁风险,因此需要仔细的死锁避免机制。在表 3.3 所示的示例中,如果加载操作 L1 和 L2 都在存储操作之后退休,并且对各自位置的一致性请求被延迟,这可能会阻止任何一个存储操作完成,从而导致死锁。
另一种方法(称为前驱序列化)要求较旧的内存操作做足够的工作 —— 通常是在一个中心点进行序列化 —— 以确保较新的操作可以安全地在其之后完成。冲突排序 [6] 允许加载和存储操作在未完成的存储缺失之后退休,只要未完成的存储操作在目录处进行序列化并确定一个全局的未完成存储操作列表;只要较新的内存操作与该列表不冲突,它就可以安全地退休。戈佩和利帕斯蒂 [4] 提出了一种专门针对顺序执行处理器的方法,其中每个加载或存储操作按程序顺序从目录获取互斥锁,但可以乱序退休。
最后,可以利用编译器或内存管理单元的帮助来确定可以安全重新排序的访问操作 [5]。例如,对线程私有或只读变量的两次访问可以安全地重新排序。
3.9 顺序一致性下的原子操作
为了编写多线程代码,程序员需要能够同步线程,而这种同步通常涉及原子地执行成对的操作。这种功能由能够原子地执行 “读 - 改 - 写”(RMW)操作的指令提供,例如著名的 “测试并设置”“获取并递增” 和 “比较并交换”。这些原子指令对于正确的同步至关重要,并且用于实现自旋锁和其他同步原语。对于自旋锁,程序员可能会使用 RMW 操作来原子地读取锁的值是否为解锁状态(例如,等于 0),并将其写入锁定值(例如,等于 1)。为了使 RMW 操作具有原子性,RMW 的读(加载)和写(存储)操作必须在顺序一致性要求的操作全序中连续出现。
在微架构中实现原子指令在概念上很简单,但简单的设计可能会导致原子指令的性能不佳。一种正确但简单的实现原子指令的方法是,核心有效地锁定内存系统(即,阻止其他核心发出内存访问请求),并对内存执行其读、修改和写操作。这种实现虽然正确且直观,但牺牲了性能。
更积极的 RMW 实现利用了这样一个见解:顺序一致性只要求所有请求看起来具有全序。因此,可以通过以下方式实现原子 RMW 操作:首先,如果块不在核心的缓存中处于 M 状态,则让核心在其缓存中获取处于 M 状态的该块。然后,核心只需要在其缓存中加载和存储该块 —— 无需任何一致性消息或总线锁定 —— 只要它在存储操作之后再处理任何传入的针对该块的一致性请求。这种等待不会有死锁风险,因为存储操作保证会完成。
回顾测验问题 3:为了执行原子读 - 改 - 写指令(例如,测试并设置),核心必须始终与其他核心进行通信。这句话是对还是错?
答案:错误!
一种更优化的 RMW 实现可以在加载部分和存储部分执行之间留出更多时间,而不违反原子性。考虑块在缓存中处于只读状态的情况。RMW 的加载部分可以立即进行推测性执行,同时缓存控制器发出缓存一致性请求,将块的状态升级为读写状态(注,M)。当块以读写状态获取后,RMW 的写部分再执行。只要核心能够维持原子性的假象,这种实现就是正确的。为了检查是否维持了原子性的假象,核心必须检查在加载部分和存储部分之间,加载的块是否从缓存中被逐出;这种推测支持与顺序一致性中检测推测错误所需的支持相同(3.8 节)。
3.10 综合示例:MIPS R10000
MIPS R10000 [21] 为实现顺序一致性并与缓存一致性内存层次结构协作的推测性微处理器提供了一个经典而清晰的商业示例。在此,我们将重点关注 R10000 与实现内存一致性相关的方面。
R10000 是一款具有分支预测和乱序执行功能的四路超标量 RISC 处理器核心。该芯片支持用于一级指令和一级数据的回写缓存,以及与(片外)统一二级缓存的私有接口。
如图 3.7 所示(改编自耶格 [21] 的图 1),该芯片的主系统接口总线支持多达四个处理器的缓存一致性。为了构建基于 R10000 且包含更多处理器的系统,例如 SGI Origin 2000(将在 8.8.1 节详细讨论),架构师实现了一种目录一致性协议,该协议通过系统接口总线和一个专用的 Hub 芯片连接 R10000 处理器。在这两种情况下,R10000 处理器核心看到的是一个一致性内存系统,该系统部分在片内,部分在片外。
在执行过程中,R10000 核心按程序顺序将(推测性)加载和存储操作发送到地址队列。加载操作从对同一地址的最后一次存储操作(如果有的话)或数据缓存中获取(推测性)值。加载和存储操作按程序顺序提交,然后从地址队列中移除其条目。为了提交存储操作,一级缓存必须将块保持在 M 状态,并且存储的值必须在提交时原子地写入。
重要的是,如果由于一致性失效或为了给另一个块腾出空间而逐出了一个缓存块,而该块包含地址队列中加载操作的地址,则该加载操作以及所有后续指令将被取消,然后重新执行。因此,当加载操作最终提交时,加载的块在执行和提交之间一直存在于缓存中,所以它必须获得与在提交时执行相同的值。由于存储操作实际上在提交时写入缓存,因此 R10000 在逻辑上按程序顺序将加载和存储操作呈现给一致性内存系统,从而实现了顺序一致性,如前文所述。
3.11 关于顺序一致性的进一步阅读资料
下面我们从围绕顺序一致性的大量文献中挑选几篇进行重点介绍。
兰波特 [12] 定义了顺序一致性。据我们所知,迈克斯纳和索林 [15] 首次证明了一个系统(其中核心按程序顺序将加载和存储操作呈现给缓存一致性内存系统)足以实现顺序一致性,尽管这个结果在一段时间内凭直觉就被人们所相信。
顺序一致性可以与数据库可串行化 [10] 进行比较。这两个概念的相似之处在于,它们都坚持所有实体的操作看起来是以串行顺序影响共享状态的。但由于操作和共享状态的性质及期望不同,这两个概念也存在差异。在顺序一致性中,每个操作是对易失性状态(内存)的单次内存访问,并且假定不会失败。在可串行化中,每个操作是对数据库的事务,可以读写多个数据库实体,并且期望遵守 ACID 属性:原子性 —— 即使出现故障也是要么全部执行要么全部不执行;一致性 —— 使数据库保持一致;隔离性 —— 不受并发事务的影响;持久性 —— 即使发生崩溃和断电,效果仍然存在。
我们遵循兰波特和 SPARC 的方法,定义了所有内存访问的全序。虽然这对有些人来说可以便于理解,但并非必要。回想一下,如果两个访问来自不同线程、访问同一位置,并且至少有一个是存储操作(或 RMW 操作),那么这两个访问就是冲突的。正如沙莎和斯尼尔 [18] 所开创的那样,我们可以不定义全序,而是只定义对冲突访问的约束,让非冲突访问不排序。这种观点对于第 5 章中的宽松模型尤其有价值。
最后,讲一个警示故事。我们之前(3.7 节)提到,检查一个推测性执行的加载操作是否可能被观察到乱序执行的一种方法是,记住加载操作推测性读取的值 A,并且如果在提交时内存位置的值仍然是 A,则提交该加载操作。马丁等人 [14] 表明,对于进行值预测的核心 [13] 来说,情况并非如此。在进行值预测时,当加载操作执行时,核心可以对其值进行推测。假设有一个核心推测对块 X 的加载操作将产生值 A,但实际值是 B。在核心对块 X 的加载操作进行推测到在提交时重新执行该加载操作之间,另一个核心将块 X 的值改为 A。然后核心在提交时重新执行加载操作,比较这两个值,发现它们相等,就错误地确定推测是正确的。如果系统以这种方式进行推测,就可能违反顺序一致性。这种情况类似于所谓的 ABA 问题(http://en.wikipedia.org/wiki/ABA_problem),马丁等人表明,在存在值预测的情况下,有方法可以检查推测,避免出现一致性违规的可能性(例如,通过重新执行所有依赖于最初推测性加载操作的加载操作)。讨论这个问题的重点不是深入探讨这个特殊的边缘情况及其解决方案的细节,而是要让你相信,应该证明你的实现是正确的,而不是依赖直觉。

1776

被折叠的 条评论
为什么被折叠?



