翻译自内存屏障

那么到底是什么让CPU的设计大师们着了魔,要把内存屏障这个鬼东西强行塞给了毫不知情的多处理器系统的软件开发者? 简单点说,就是因为内存访问顺序的重排会带来更好的性能。同步原语的正确操作依赖于重排后的内存访问,因此需要使用内存屏障来强制对同步原语进行排序。

想要详细答案就需要很好的理解CPU缓存如何工作的,尤其需要理解什么才能让缓存真正工作良好。我们会分下面几个部分深入了解:

1.展现缓存结构
2.描述缓存一致性协议如何确保多个CPU在内存中每个位置的值一致
3.概述 store buffers 和 invalidate queues 如何帮助缓存和缓存一致性协议来获取高性能
复制代码

我们会明白为了获得更好的性能和高可扩展性,内存屏障是必要之恶。罪恶的源头都是出于:多CPU比它们之间的互连器和内存都快几个数量级。

1 Cache Structure

现代CPU比现代内存系统快很多。一个2006年的CPU可能达到每纳秒执行十条指令,但是却需要数十纳秒去主内存读取数据。这种速度上的差异——超过两个数量级——导致了在现代CPU的兆字节缓存的出现。这些缓存与CPU关联,如图1所示,通常可以在几个周期内完成访问[1]。


数据在CPU的缓存和内存之间流动时,以固定长度的块,称为“缓存行”,这些缓存行的大小通常是2的幂,从16字节到256字节不等。当给定的数据项首次被给定的CPU访问时,它将不在CPU的缓存里,这意味着“缓存脱靶”(或者更具体地说,发生“启动”或“预热”缓存脱靶)。缓存脱靶意味着CPU将不得不等待(或“停滞”)数百个周期,以从内存中获取数据项时,但是随后该项会被加载到该CPU的缓存中,因此随后的访问将在缓存中找到它,并因此全速运行起来。


一段时间后CPU的缓存将要填满,为了给新获取的条目腾出空间,需要从缓存中弹出一个旧条目,这时再获取之前弹出的缓存就会脱靶。这种缓存脱靶称为“容量脱靶”,因为它是由缓存的有限容量引起的。然而,即使CPU缓存还没有满,大多数缓存也可能被迫弹出旧项,以便为新项腾出空间,这是因为大型缓存是用硬件哈希表实现的,哈希表有固定大小的哈希桶(或CPU设计人员称为“sets”),并没有用链表(译者注:类似线性探测法哈希表,但限制了探测范围),如图2所示


该缓存共有16个“set”和2个“way”,共32个“line”,每个“line”包含一个256字节的“缓存行”,这是一个256字节对齐的内存块。缓存行的大小是有点大了,但是可以让16进制的算术更简单。在硬件术语中,这是一种双通道关联集缓存,类似于具有16个桶的软件哈希表,其中每个桶的哈希链最多两个元素(译者注:前面已经提到不是链表,这里仅类比软件开发中用到的链地址法哈希表)。总大小(这里是32个缓存行)和 关联性大小(这里是2个缓存行)被统称为缓存的“几何结构”。由于这个缓存是在硬件中实现的,所以哈希函数非常简单:从内存地址中提取4比特位(译者注:4比特位表示0x0 到 0xF)。


在图2中,每个框对应一个缓存条目,每个条目包含256字节的缓存行。但是缓存条目可以是空的,如图中的空框所示。其余的框使用它们所包含的缓存行的内存地址进行标记。因为缓存行必须是256字节对齐的,所以每个地址的低8位是零,选择的硬件哈希函数表明下一个高4位(表示0x0 到 0xF)与哈希行号相等.


如果程序的代码位于地址0x43210E00到0x43210EFF之间,并且程序从0x12345000到0x12345EFF依次访问数据,那么就会出现图中所示的缓存结构(译者注:这里缓存既可以放代码指令,又可以存储数据,也就是L1缓存,由于缓存行大小时256字节,0x43210E00到0x43210EFF的指令刚好可以放到一个缓存行中,以此类推,0x0 到 0xE的第一通道刚好被填满)。假设程序现在要访问位置0x12345F00,这个地址散列到0xF行,这一行的两个通道都是空的,因此可以容纳对应的256字节行。如果程序访问位置0x1233000,它将散列到第0x0行,那么对应的256字节缓存行可以被安置在通道1(Way 1)。但是,如果程序要访问位置0x1233E00(它散列到第0xE行),则必须从缓存中弹出一个缓存行,以便为新的缓存行腾出空间。如果稍后访问此弹出的缓存行,则会导致缓存脱靶。这种缓存脱靶称为“关联性脱靶”。


到目前为止,我们只考虑CPU读取数据项的情况。当CPU写数据的时候会发生什么? 由于当所有的CPU读取给定的数据项的值的时候要保持一致性很重要,在给定CPU写入那个数据项之前,必须首先从其他CPU的缓存中删除该数据项,也就是使其“invalidate”。一旦“invalidate”完成,CPU可以安全地修改数据项。如果数据项存在于CPU的缓存中,但它是只读的,则此过程称为“写入脱靶”。一旦给定的CPU从其他CPU的缓存中完成对给定数据项的“invalidate”操作,该CPU就可以会重复写入和读取该数据项了


稍后,如果其他CPU尝试访问数据项,它将导致缓存脱靶,这一次是因为第一个CPU为了写入数据项而使该其他CPU的数据项“invalidate”。这种类型的缓存脱靶称为“通信脱靶”,因为它通常是由于多个CPU使用共享数据项进行通信的缘故(例如,CPU间互斥算法中使用的锁)。


显然,必须得花大力气维护所有CPU缓存中数据的一致性视图。通过读取、失效和写入操作,很容易想到数据会丢失,或者可能更糟糕的情况,如不同的CPU在各自的缓存中对相同地址的数据项具有冲突的值。下一节将介绍“缓存一致性协议”,它可以解决这些问题。


2 Cache-Coherence Protocols(缓存一致性协议族)

缓存一致性协议族管理着缓存行(cache-line)的状态,以防止数据不一致或丢失。这些协议可能非常复杂,有几十种状态[2],但是出于我们的目的,今天我们只需要关注包含四种状态的缓存一致性协议MESI。(译者注:为了保持一致性,CPU大师们设计了各种模型和协议,如MSI、MESI(又名Illinois)、MOSI、MOESI、MERSI、MESIF、write-once、Synapse、Berkeley、Firefly和Dragon等协议。)

2.1 MESI States


MESI代表“修改(modified)”、“独占(exclusive)”、“共享(shared)”和“无效(invalid)”,这是使用此协议的缓存行可以呈现的这四种状态。因此,使用此协议的缓存在每条缓存行上除了维护该行的物理地址和数据外,还要维护一个占两比特的状态“标记”。


M : CPU的一个内存存储的操作会让缓存行处于“修改”状态,并且保证该内存值不会出现在任何其他CPU的缓存中,因此处于“修改”状态的缓存行可以说是由该CPU“独有”的。由于这个缓存保存着仅有的最新数据的副本,所以缓存最终是要将副本写回内存或传递给其他缓存的,而且在重复使用这一缓存行之前必须这样做,以保存其他地址的数据。


E : “独占”状态与“修改”状态非常相似,唯一的区别是缓存行还没有被相应的CPU修改,这意味着保留在内存中的缓存行数据副本是最新的。但是因为CPU可以在任何时候存储数据到这一缓存行,而不需要询问其他CPU,所以处于“独占”状态的缓存行仍然可以说是属于该CPU所“独有”的。也就是说,因为内存中的值就是最新的,所以无需将其写回内存或将其传递给其他CPU,缓存就可以丢弃最新的副本以为其他数据腾出空间


S : 处于“共享”状态的行可能被复制到其他CPU的缓存中,因此不允许该CPU在不与其他CPU协商的情况下存储数据到该行中(译者注:这时缓存行是只读状态,执行写入操作会出现“write miss”)。与“独占”状态一样,由于内存中对应的值是最新的,因此该缓存可以在不回写回数据或没有将其传递给其他CPU的情况下丢弃该数据。


I: 处于“invalid”状态的缓存行是空的,换句话说,它不保存数据。当有新数据进入缓存时,它将可能被放置到“无效”状态的缓存行中,这种方式是首选的,如果将来查询被替换的缓存行,那么替换任何其他状态的行都可能导致昂贵的缓存脱靶(译者注:也就是M、E、S状态 都会导致的缓存脱靶)。


由于所有CPU都必须维护缓存行所携带数据的一致性视图,为此MESI缓存一致性协议提供了多种消息来协调缓存行数据在多处理器系统中的迁移。

2.2 MESI Protocol Messages

上一节中描述的状态的转换需要多个CPU之间进行通信。如果多个CPU共享一个总线,那么下面的通信消息就足够了:

Read: “read”消息包含要读取的缓存行的物理地址。Read Response:“read response”消息包含先前的“read”消息请求的数据。这个“read response”消息可能由内存或其他缓存提供。例如:如果其中一个处于“modified”状态的缓存持有所需的数据,则该缓存必须提供“read response”消息Invalidate:“invalidate”消息包含即将要被无效化的缓存行的物理地址。其他CPU的缓存必须移除缓存行的数据,然后返回“Invalidate Acknowledge”消息Invalidate Acknowledge: CPU接收到“无效”消息后,在移除缓存里特定的数据后就会返回“Invalidate Acknowledge”消息Read Invalidate: “read invalidate”消息包含要读取的缓存行的物理地址,同时指示其他CPU缓存删除此数据。因此,它是“read”和“invalidate”的组合,如其名称所示。“read invalidate”消息既需要“read response”,也需要一组“invalidate acknowledge”消息的回复。Writeback:“writeback”消息包含地址和要写回内存的数据(可能会在这个过程中数据被其他CPU的缓存“嗅探”到以达到数据一致性)(译者注: 缓存一致性的原理一般基于两种:“嗅探(snooping)“和“基于目录(directory-based)”)。当需要为其他数据腾出空间时,此消息还允许“modified”状态的缓存行被弹出。
复制代码

有趣的是,共享内存多处理器系统实际上是隐式的消息传递计算机系统。这意味着分布式共享内存的SMP机器集群使用消息传递在两个不同级别的系统体系结构上实现共享内存。


快速问答1:如果两个CPU试图同时使同一缓存行失效,会发生什么情况?(译者答:这会导致两个CPU的缓存行都会无效)
快速问答2:当一个“invalidate”消息出现在一个大型多处理器中时,每个CPU必须给出一个“invalidate acknowledge”响应。由此产生的“invalidate acknowledge”响应“风暴”不会完全淹没系统总线吗?(译者答:不会。为了防止总线竞争,在任何时刻只有一个设备驱动系统总线。)
快速问答3:如果SMP系统真的在使用消息传递,那么为什么还要使用SMP呢?(译者注:最开始的SMP系统是没有缓存的,这些消息传递都是随着缓存一起被引入.
复制代码

2.3 MESI State Diagram

在发送和接收协议消息时,给定的缓存行的状态会如图3所示发生变化。

图中所有转换如下:

转换(a): 缓存行被写回内存,但是CPU将它保留在缓存中,并进一步保留修改它的权利。此转换需要“writeback”消息。

转换(b): CPU写入数据到独占状态的缓存行。此转换不需要发送或接收任何消息。

转换(c): 已经被修改的缓存行对应的CPU接收到了 “read invalidate”消息,CPU必须使其存储的本地副本无效,然后使用“read response”和“invalidate acknowledge”消息进行应答,这两个消息都将发送数据给请求的CPU,并表明它不再具有本地副本。

转换(d):CPU对缓存中不存在的数据项执行读-修改-写的原子操作。它发送一个“read invalidate”,通过“read response”接收到数据。一旦CPU收到一组完整的“invalidate确认”响应,它就可以完成转换(译者注:执行读-修改-写的原子操作)。

转换(e):CPU对缓存中是只读的数据项执行读-修改-写的原子操作。它必须传输“invalidate”消息,并且在完成转换之前必须等待一组完整的“invalidate acknowledge”响应。

转换(f): 当前CPU独占该缓存行(M 状态),其他CPU需要读取该缓存行,缓存行这时会变成只读副本状态(S 状态),还需要将其写入内存。此转换由接收“read”消息开始,当前CPU使用包含所请求数据的“read response”消息进行响应。

转换(g): 当前CPU独占该缓存行(E 状态),其他CPU需要读取该缓存行或者内存,缓存行这时会变成只读的副本(S 状态)。此转换由接收“read”消息开始,当前CPU使用包含所请求数据的“read response”消息进行响应。

转换(h): 当前CPU将需要写入一些数据项到这个处于S状态的缓存行中,因此传递一条“invalidate”消息。在接收到一组完整的“invalidate acknowledge”响应之前,CPU无法转换到E状态。第二种方式是让所有其他CPU通过“writeback”消息使它们的缓存弹出这条缓存行(可能是为了给其他缓存行腾出空间),这样当前CPU就是最后一个缓存它的CPU。

转换(i): 其他一些CPU对仅保存在当前CPU缓存中的数据项执行 读-修改-写 原子操作,因此该CPU使该数据项对应的缓存行无效。此转换由接收“read invalidate”消息开始,该CPU同时使用“read response”和“invalidate confirm”消息进行响应

转换(j):这个CPU对本地缓存中不存在的数据项进行写操作,因此传递一条“read invalidate”消息。在接收到“read response”和一组完整的“invalidate acknowledge””消息之前,CPU无法完成转换。一旦存储完成,缓存行将可能会通过转换(b)变为“修改”状态。

转换(k): 此CPU加载不在其缓存中的数据项。CPU发送一个“read”消息,在接收到相应的“read response”后完成转换。

转换(l): 其他某个CPU对这条缓存行中的数据项进行写操作,但是由于被另外的CPU缓存也保存着(比如当前的CPU缓存),而处于只读的S状态。此转换由接收“invalidate”消息开始,当前CPU将以“invalidate acknowledge”消息进行响应

快速问答4:硬件如何处理上面描述的消息传递导致的延迟?译者答:CPU并不会停下来等待消息,而是继续执行下面的指令,等消息回来时再继续处理
复制代码

2.4 MESI Protocol Example

现在让我们从缓存行的数据值的角度来看这个协议,这些数据最初都在地址为0的内存中,它在包含4个CPU的单通道直接映射缓存的系统中流通,也就是缓存的“Way”只有一种。表1显示了数据的流向,第一列显示的是操作序列,第二列是执行操作的CPU,第三所执行的操作,接下来的是四个CPU的缓存行状态(内存地址后面跟着MESI状态,像这样:地址/状态),最后两列对应的是内存内容是否是最新,“V”表示有效,内存中是最新数据,意味着和缓存中数据保持一致,“I”表示无效,内存中的数据已经过时,与缓存中数据不一致。


最初,CPU缓存行处于“invalidate”状态,内存中的数据都是最新的有效的内容(V状态)。当CPU 0加载地址为0的数据时,CPU 0 的缓存中变为“shared”状态,并且内存中还是最新的有效的数据。CPU 3也加载地址为0的处数据,因此地址为0的数据在这两个CPU的缓存中都处于“shared”状态,并且内存中的数据仍然是有效。接下来,CPU 0加载地址8的数据,这需要通过发送“invalidate”消息将地址0的数据强制从缓存行中剔除,并将其替换为地址8上的数据(译者注:由于上面已经假设运行在4CPU的单行通道直接映射缓存系统,地址8和地址0恰好hash到同一个缓存行)。CPU 2 从地址0 加载数据,但这CPU意识到它将很快需要存储数据,所以它使用一个“read invalidate”消息获得独占副本(E状态),使 CPU 3 缓存的副本无效(I状态)(此时内存地址0的数据仍然是最新有效的)。接下来CPU 2进行预期的存储,将状态更改为“modified”,此时内存地址0的数据已经过时了。CPU 1执行一个原子增量,使用“read invalidate”消息,从CPU 2的缓存中嗅探到数据并使其无效,从而使CPU 1的缓存中的副本处于“modified”状态(并且内存中的副本仍然是过时的)。最后,CPU 1读取地址8的缓存行,由于缓存行已经被地址0占用,因此使用“write back”消息先将地址0的数据写回到内存中,然后弹出地址0相应的缓存数据,再把地址8的数据读取到缓存行中,此时状态为“shared”。


注意到:最后的结果就是上面一些CPU的缓存中有了数据。

快速测试5:什么样的操作序列会使上面CPU的缓存全部恢复到“无效”状态?即清空所有的缓存行的数据。(译者思考:最简单就是给包含地址8的缓存发送“invalidate”消息)
复制代码

3 Stores Result in Unnecessary Stalls

尽管图1所示的缓存结构为从给定CPU到给定数据项的重复读写提供了良好的性能,但是对于给定缓存行的第一次写入,其性能就相当差。要了解这一点,请看图4,它显示了CPU 0 对 CPU 1缓存中的数据执行写入操作的时间轴。由于CPU 0没有此缓存行,必须等待CPU 1 的缓存行到达,然后才能写入该缓存行,从而CPU 0必须停下一段时间[3]。


但是没有真正的理由强迫CPU 0 停下这么长时间——毕竟,不管CPU 1 发送给它的缓存行里是什么样的数据,CPU 0都会无条件地覆盖它。

3.1 Store Buffers

防止这种不必要的写入延迟的一种方法是在每个CPU及其缓存之间添加“存储缓冲区”,如图5所示。通过添加这些存储缓冲区,CPU 0可以简单地在其存储缓冲区中记录其写操作并继续执行。当缓存行最终从CPU 1 送达 CPU 0时,数据将从存储缓冲区移动到缓存行。但是,有一些复杂的问题必须加以解决,这些问题将在下两节中讨论。

3.2 Store Forwarding

先查看第一个复杂的情况,那就是违反了自洽性(也就是缓存一致性),请看下面的代码,其中变量“a”和“b”最初都为零,起初,包含变量“a”的缓存行属于CPU 1,而包含变量“b”的缓存行属于CPU 0:

a = 1
b = a + 1
assert(b == 2)
复制代码

没人料到上面的断言会失败。然而,如果你愚蠢到使用图5中所示的如此简单的Store buffer体系结构,你会得到意外的结果。上面这样的系统可能会遇到以下一系列事件:

1. CPU 0 开始执行 a = 1
2. CPU 0 在缓存中查找“a”,结果出现缓存脱靶
3. 因此CPU 0 发送一个“read invalidate”消息,以获得包含“a”的缓存行的独占所有权。
4. CPU 0 把要写入a的数据 “1” 记录在存储缓冲区中(Store Buffer)
5. CPU 1接收“read invalidate”消息时,返回包含a的缓存行数据,同时从缓存中删除缓存行。
6. CPU 0 开始执行 b = a + 1
7. CPU 0 接收到CPU 1的缓存行,此时缓存行里“a”的值仍然为0
8. CPU 0 从缓存加载“a”,发现值为0。
9. CPU 0 将存储缓冲区中的数据“1”移到最新接收到的缓存行中,也就是将刚接收到的缓存行中的“a”设置为1。
10. CPU 0 在为上面的“a”的值“0”上添加上1,并将其结果写入到包含“b”的缓存行中(我们假设CPU 0已经拥有b的缓存)。
复制代码

上面的问题在于我们有两个“a”副本,一个在缓存中,另一个在存储缓冲区中。 上面这个例子破坏了一个非常重要的原则,即每个CPU执行的命令应该和编码顺序一样。破坏了这种原则对于软件开发来说就是违反直观逻辑的。幸运的是这得到了硬件工程师的同情并实现了“存储转发”(store forwarding),其中每个CPU在加载数据时会同时访问(或“嗅探”)自己的存储缓冲区和缓存(译者注:CPU都只能访问自己的存储缓冲区),如图6所示,CPU 优先访问“Store Buffer”,如果没有找到则去缓存寻找,同时还会把结果放入“Store Buffer”。换句话说,不需要通过缓存,就可以把CPU的存储缓冲区的值直接转发给后续的读取操作。(译者注:这样看来Store Buffer 类似没有遵循缓存一致性协议的缓存了,各个CPU只关注自己的Store Buffer)

有了Store Forwarding之后,上面序列中的第8步将在存储缓冲区中为“a”找到正确的值1,因此“b”的最终值应该是2,和我们预料的一样。

上面是比较简单的场景,第二种复杂的情况如下:

void foo(void)
{a = 1;b = 1;
}void bar(void)
{   while(b == 0) continue;assert(a == 1);
}
复制代码

假设CPU 0 执行foo(), CPU 1 执行bar()。进一步假设包含“a”的缓存线只驻留在CPU 1的缓存中,并且包含“b”的缓存行属于CPU 0。那么执行操作顺序可以是:

   1. CPU 0 执行 a = 1。包含a缓存行不在CPU 0的缓存中,因此CPU 0将a的新值“1”放在其存储缓冲区中,并传递一条“read invalidate”消息。2. CPU 1 执行 while (b == 0) continue ,但是包含“b”的缓存行不在其缓存中。因此,它发送一个“read”消息。3. CPU 0 执行 b = 1。它已经拥有这条缓存行(换句话说,缓存行已经处于“modified”或“exclusive”状态),因此它将新值“b”存储在其缓存行中。4. CPU 0 接收到“read”消息,并将包含当前更新的值“b”的缓存行发送给CPU 1,同时在自身缓存中将该行标记为“shared”。5. CPU 1 接收包含“b”的缓存行,并将其安置在其缓存中。6. CPU 1 现在可以完成执行 while (b == 0) continue,因为它发现“b”的值是1,所以它继续执行下一条语句。7. CPU 1 执行 assert(a == 1),由于CPU 1使用的是旧值“a = 0”,断言失败。8. CPU 1 接收到“read invalidate”消息,将包含“a”的缓存行发送给CPU 0,并从自己的缓存中使该缓存行失效。但已经太迟了。9.  CPU 0接收到包含“a”的缓存线,并及时应用缓冲存储。CPU 0 本可以正常工作,反而 因 CPU 1 断言失败也终止了,成为了受害者!
复制代码

快速测试6:在上面的步骤1中,为什么CPU 0需要发出一个“read invalidate”而不是一个简单的 invalidate”?(译者答:CPU 0因为需要修改a,必须先需要独占a,而本地缓存中没有a,故要发送“read invalidate”消息)

硬件设计人员在这里不能直接提供帮助,因为CPU不知道哪些变量是相关的,更不知道它们是如何相关的。因此,硬件设计人员提供内存屏障指令,允许软件工程师告诉CPU这种关系。修改程序片段,其中包含内存屏障:

void foo(void)
{a = 1;smp_mb();b = 1;
}void bar(void)
{   while(b == 0) continue;assert(a == 1);
}
复制代码

在内存屏障smp_mb()后续的的存储操作开始执行之前,CPU会清空其存储缓冲区,把值转移到每个变量对应的缓存行,也就是把之前存储在缓冲区的值应用到缓存行。CPU可以简单粗暴地暂停一会,直到存储缓冲区为空,然后再继续后续的存储。但是让CPU等待是差强人意的方式。第二种更好方式:我们可以同时把后续的存储操作放到存储缓冲区,直到我们把smp_mb()之前的存储缓冲区中的条目都应用到缓存行时,才可以把后续的存储操作应用到缓存行。


使用后一种方法,操作顺序可能如下:

1. CPU 0 执行 a = 1。包含a的缓存行不在CPU 0的缓存中,因此CPU 0将新值“a”放在其存储缓冲区中,并传递一条“read invalidate”消息。
2. CPU 1 执行 while (b == 0) continue ,但是包含“b”的缓存行不在其缓存中。因此,它发送一个“read”消息。
3. CPU 0 执行smp_mb(),并标记所有当前存储缓冲区条目(即,a=1)。
4. CPU 0 执行b=1。它已经拥有这条缓存线(换句话说,缓存线已经处于“modified”或“exclusive”状态),但是由于存储缓冲区中已经有了一个标记条目,因此,它不是将新值“b”存储在缓存行中,而是将其放置在存储缓冲区中(但在未标记的条目中)。
5. CPU 0 接收到“read”消息,并将包含“b = 0”原始值的缓存行发送给CPU 1。它还将自己的缓存行副本标记为“shared”。
6. CPU 1 接收包含“b”的缓存行,并将其放在其缓存中。
7. CPU 1 现在可以加载“b”的值,但是因为它发现“b”的值仍然是0,所以它重复while语句。新值“b = 1”安全地隐藏在CPU 0的存储缓冲区中。
8. CPU 1 接收到“read invalidate”消息,将包含“a”的缓存行发送给CPU 0,并从自己的缓存中使该缓存行失效。
9. CPU 0 接收到包含“a”的缓存行,这时会触发存储缓冲区中的值写入缓存行,并将该行置于“modified”状态。
10. 由于“a”的存储是存储缓冲区中唯一由smp_mb()标记的条目,处理完被标记的条目后,CPU 0接着就可以存储“b”的新值到缓存行,但是有这么一个事实需要注意:包含“b”的缓存行现在处于“共享只读”状态
11. 因此CPU 0 需要向CPU 1发送一条“invalidate”消息,使CPU 1清空包含b的缓存行。
12. CPU 1 接收到“invalidate”消息,将包含“b”的缓存行从缓存中清空,并将“acknowledgement”消息发送给CPU 0。
13. CPU 1 执行 while (b == 0)continue,但是包含“b”的缓存行不在其缓存中。因此,它向CPU 0发送一个“read”消息。
14. CPU 0 接收到“acknowledgement”消息,将包含“b”的缓存行置于“exclusive”状态。CPU 0现在将新值“b = 1”存储到缓存行中。
15. CPU 0 接收到“read”消息,并将包含新值“b”的缓存行发送给CPU 1。并将自己的缓存行标记为“shared”。
16. CPU 1 接收到包含“b”的缓存行,并将其放在其缓存中。
17. CPU 1 现在可以加载“b”的值,由于发现“b”的值是1,所以退出while循环并继续下一条语句
18. CPU 1 执行 assert(a==1),但是包含“a”的缓存行不再在其缓存中。一旦它从CPU 0获得这个缓存,它将使用最新的值“a = 1”,因此断言将通过。
复制代码

如您所见,这个过程涉及大量的记录工作。即使是一些直观上很简单的东西,比如“加载a的值”,在硬件芯片中也会涉及很多复杂的步骤。

4 Store Sequences Result in Unnecessary Stalls

不幸的是,每个存储缓冲区较小,这意味着执行少量存储序列的CPU可以填满其存储缓冲区(如果所有都是缓存脱靶导致的)。此时,CPU必须再次停下来等待“invalidate acknowledge” 消息的返回,以便在继续执行指令之前清空其存储缓冲区。而且上一节最后讨论的b的存储操作在内存屏障之后也会出现CPU停下来等待“invalidate acknowledate”的情况,所以无论这些存储是否会导致缓存脱靶都会让CPU等待失效确认消息。


显而易见,这种情况可以通过使“invalidate acknowledge”消息更快返回来改善上面两种场景导致的CPU等待时间。一来可以加快存储缓冲区的清空速度,二来加快了正常的回复速度。有一种改善方法是:使用单CPU无效消息队列,也就是“invalidate queues”。

4.1 Invalidate Queues

"invalidate acknowledge"消息占用这么长时间的一个原因是:他们必须确保相应的缓存行真正无效后才发送回复。并且如果缓存处于繁忙,“invalidate”的确认消息还会被推迟。例如:如果当前CPU正在密集加载和存储数据,且所有这些数据都存在缓存中(译者注:意味着当前CPU不需要发送“invalidate” 消息),此外,这时如果还有大量的“invalidate”消息瞬间到达当前CPU,则当前的CPU无法及时处理这些“invalidate”消息,从而可能会使所有其他发送“invalidate” 消息的CPU陷入等待回复的状态。


但是,在发送‘invalidate’的确认消息之前,CPU实际上不需要使缓存行真正失效。相反,它可以把无效消息放入队列中,但必须在CPU发送针对此缓存行的相关消息之前,对该无效消息进行处理,也就是清空相关缓存行。

4.1 Invalidate Queues and Invalidate Acknowledge

图7显示了一个带有无效队列的系统。具有无效队列的CPU可以在无效消息被放入队列时立即回复确认无效,而不必等到相应的行真正无效。当然,在准备发送失效消息给指定CPU时,CPU必须先查看它的失效队列,如果相应缓存行的条目在失效队列中,CPU不能立即发送失效消息;相反,它必须等待,直到处理了无效队列里的条目。


在“invalidate queue”中放置条目本质上就是承诺:CPU在传递任何关于该缓存行的MESI协议消息之前一定会处理该条目。只要相应的数据结构不是高度竞争的,CPU就很少会因为这样的承诺而不便。


然而,缓存了失效消息可能引入“memory-misordering”问题,下一节将对此进行讨论。

4.3 Invalidate Queues and Memory Barriers

我们假设所有CPU缓存无效请求,并立即响应它们。虽然这种方法最小化了CPU执行存储时所带来的缓存失效延迟,但是却可以让内存障碍不起作用,如下面的示例所示:


假设“a”和“b”的值最初为零,“a”是只读复制的(“共享”状态),“b”为CPU 0所有(“独占”或“修改”状态)。然后假设CPU 0执行foo(), CPU 1执行函数bar(),代码片段如下:

 void foo(void){a = 1;smp_mb();b = 1;}void bar(void){while (b == 0) continue;assert(a == 1);}
复制代码

那么操作顺序可以是:

1. CPU 0 执行 a=1。对应的缓存行在CPU 0的缓存中是只读的,因此CPU 0将新值“a”放入其存储缓冲区中,并传输“invalidate”消息,以便从CPU 1的缓存中刷新对应的缓存行
2. CPU 1 执行 while (b == 0) continue ,但是包含“b”的缓存线不在其缓存中。因此,它发送一个“read”消息。
3. CPU 1 接收 CPU 0 的“invalidate”消息,放入队列,并立即响应。
4. CPU 0 从 CPU 1接收响应,因此可以自由地通过上面第4行中的smp_mb(),将“a”的值从其存储缓冲区移动到其缓存行。
5. CPU 0 执行 b=1 。它已经拥有这条缓存行(换句话说,缓存行已经处于“modified”或“exclusive”状态),因此它将新值“b”存储在其缓存行中。
6. CPU 0 接收到“read”消息,并将包含当前更新的值“b”的缓存行发送给CPU 1,同时将该缓存行标记为“shared”。
7. CPU 1 接收到包含“b”的缓存行,并且放在自己缓存中
8. CPU 1 现在可以完成执行 while (b == 0) continue ,这是因为它发现b的值是1,CPU 1 继续执行下面的语句。
9. CPU 1执行 assert(a==1),由于旧的值“a”仍然在CPU 1的缓存中,因此断言失败。
10. 尽管断言失败,但是CPU 1 开始处理“invalidate queue”里的消息,缓慢地清空包含a的缓存行。
复制代码

快速测试7:在4.3节第一个场景的第1步中,为什么发送的是“invalidate”消息而不是“read invalidate”消息? CPU 0 不需要与“a”共享这条缓存行的其他变量的值吗?(译者答:因为a已经存在于CPU 0 的缓存中,且是“shared”状态,不需要读取其他CPU的缓存。这里我们只关注a的值。)

如果这样做会导致内存屏障被忽略,那么加速无效响应的速度显然没有多大意义。然而,memory barrier指令可以与无效队列进行交互,因此当给定的CPU执行一个内存屏障时,它会标记其无效队列中当前的所有条目,并强制所有后续加载操作等待所有标记的条目都被应用到CPU的缓存中,也就是等待所有标记的都清空后再可以进行后面的加载操作。因此,我们可以在函数栏中添加内存屏障如下:

 void foo(void){a = 1;smp_mb();b = 1;}void bar(void){while (b == 0) continue;smp_mb();assert(a == 1);}
复制代码

你说什么? ? ?既然CPU不可能在while循环完成之前执行assert(),那么为什么我们在这里需要一个内存屏障呢?

通过这种改变,操作顺序可能如下:

 1. CPU 0 执行a=1。对应的缓存行在CPU 0的缓存中是共享只读的,因此CPU 0将新值“a”放入其存储缓冲区中,并传输“invalidate”消息,以便从CPU 1的缓存中清空对应的缓存行。2. CPU 1 执行 while(b == 0) continue, 但是包含b的缓存行不在其缓存中,它会因此发送“read” 消息3. CPU 1 接收到 CPU 0 的“invalidate” 消息,并且放入队列中,然后立刻回复此消息。4. CPU 0 接收到CPU 1的回复,然后自行通过上面的第4行的smp_mb(),移动存储缓冲里a的值到缓存行中。5. CPU 0 执行 b = 1. 它已经拥有这个缓存行(换句话说,这个缓存行已经处于“modified”或者“exclusive” 状态),因此它存储新的b值到缓存行中。6. CPU 0 接收到“read” 消息,然后传送包含最新值b的缓存行到 CPU 1 中,并且在标记此缓存行为“shared”7. CPU 1 接收到包含b的缓存行,并且放置在自己缓存行中8. CPU 1 现在可以完成执行 while (b == 0) continue, 因为它发现b的值变为1,接着开始执行下面的语句,也就是内存屏障。9. CPU 1 现在必须停下来,直到它处理完所有之前存在于“invalidate queue”里的消息10. CPU 1现在继续处理队列里的“invalidate” 消息,然后在自己的缓存里使包含a的缓存行无效,也就是清空缓存行。11. CPU 1 执行 assert(a == 1), 然后因为包含a的缓存行已经不在缓存里了,它发送一条 对 a 的“read” 消息12. CPU 0 用包含新值a的缓存行回复 ”read“ 消息13. CPU 1 接收这条缓存线,其中包含一个值为1 的 “a”,这样断言就不会触发了。(大吉大利!终于可以吃鸡了?)
复制代码

通过大量的MESI消息传递,CPU得到了正确的答案。本节说明了为什么CPU设计人员必须非常小心地进行缓存一致性优化。

5 Read and Write Memory Barriers

在上一节中,内存屏障用于标记存储缓冲区(store buffer)和无效队列(invalidate queue)中的条目。但是在我们的代码片段中,foo()没有理由对无效队列做任何事情,bar()也没有理由对存储队列做任何事情。


因此许多CPU架构提供了较弱的内存屏障指令,这些指令只能执行这两种指令中的一种。粗略地说,read memory barrier 只标记无效队列,write memory barrier只标记存储缓冲区,而完整的内存屏障两者都标记。


这样做的影响是一个读内存屏障会调整当前CPU执行的加载操作(load)顺序,所有在read memory barrier之前的加载操作将要在它之后的加载操作之前完成。类似,write memory barrier 也会调整存储操作,当然也是在当前的CPU执行的存储操作。也就是所有在write memory barrier之前的存储操作将要在它之后的存储操作之前完成。一个完整的内存屏障同时调整加载和存储操作的顺序,但也还是仅仅在当前执行内存屏障的CPU上。


如果我们更新foo和bar来使用读写内存屏障,它们会如下所示:

 void foo(void){a = 1;smp_wmb();b = 1;}void bar(void){while (b == 0) continue;smp_rmb();assert(a == 1);}
复制代码

有些计算机甚至有更多类型的内存屏障,但是理解三种内存屏障为以后学习其他类型的屏障提供了很好的入门介绍。

[1]标准的做法是使用多个级别的缓存,一个较小的一级缓存靠近CPU,具有单周期访问时间;一个较大的二级缓存具有较长的访问时间,可能大约有10个时钟周期。性能更好的CPU通常有三层甚至四层缓存。

[2]参见卡尔等人著作的[CSG99]第670和671页,分别介绍了SGI Origin2000和Sequent NUMA-Q(现在是IBM的)的9种状态图和26种状态图。这两个图都比实际简单得多。

[3]将缓存行从一个CPU的缓存传输到另一个CPU的缓存所需的时间通常比执行简单的寄存器到寄存器指令所需的时间多几个数量级。

[4]希望详细了解真实硬件架构的读者可以参考CPU供应商的手册 [SW95, Adv02, Int02b, IBM94, LSH02, SPA94, Int04b,Int04a, Int04c], Gharachorloo的论文[Gha95],或者Peter Sewell的作品[Sew]。

译者注:

[CSG99]《并行计算机架构: 硬件/软件相结合的设计与分析方法》作者 卡尔,辛格,古普塔,1999年摩根考夫曼。 一个充满了关于并行机器和算法的细节的宝藏。正如马克·希尔在书的封面上幽默地评论的那样,这本书包含的信息比大多数研究论文都要多。

转载于:https://juejin.im/post/5c4719576fb9a049fb43fe55

# 内存屏障:骇客的硬件视角(1)相关推荐

  1. 金士顿的骇客神条VS普通内存条

    金士顿内存条的市场份额相当大,不少用户都喜欢购买金士顿内存条,但是购买内存条的时候你会发现,在频率和容量一样的情况下,有两种金士顿内存条,一个是金士顿内存条,一个叫金士顿骇客神条,他们有什么区别呢?为 ...

  2. Java指令屏障_指令重排序和内存屏障

    sap hana计算技术项目实战指南内存 61元 (需用券) 去购买 > 一.指令重排序 指令重排序分为三种,分别为编译器优化重排序.指令级并行重排序.内存系统重排序.如图所示,后面两种为处理器 ...

  3. 全网最硬核 Java 新内存模型解析与实验 - 3. 硬核理解内存屏障(CPU+编译器)

    个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判.如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 i ...

  4. 微星B450主板为硬件保留内存太大释放教程 AMD和迫击炮钛金版主板和amd处理器还有骇客神条的内存提示灯亮不开机 兼容性问题解决保留内存。

    我的硬件是 AMD2700X 和 微星B450迫击炮钛金版,配 骇客神条 8G 2666hz X2 问题嘛,主要是两个: 有时不能开机,症状: 插23组双通道,主板内存提示灯亮,报错.cpu风扇正常转 ...

  5. 记一次Y7000P内存升级(骇客神条套条),让电脑飞起来~

    先检查电脑内存型号 是DDR4还是DDR3,我的是DDR4 内存频率需要和电脑之前的一致,我这里是2666MHz 再检查电脑支持最大内存 我这里支持最大内存就是32G,台式机会支持大一些 上某东或某宝 ...

  6. linux内核内存屏障,从硬件引申出内存屏障,带你深入了解Linux内核RCU

    本文简介 本文从硬件的角度引申出内存屏障,这不是内存屏障的详尽手册,但是相关知识对于理解RCU有所帮助.这不是一篇单独的文章,这是<谢宝友:深入理解Linux RCU>系列的第2篇,前序文 ...

  7. 【Java】Volitile的作用、JVM规范如何要求内存屏障、硬件层级内存屏障如何帮助java实现高并发 - 第二天笔记

    第二天笔记 Volitile的使用 保持线程可见性 禁止指令重排:单线程中,两条指令的执行前后顺序不会影响执行结果,CPU流水线会优化执行顺序 如果存在乱序,则不可能出现x=0,y=0的结果 运行结果 ...

  8. 多核心CPU并行编程中为什么要使用内存屏障 memory barriers / 内存栅栏 memory fence

    文章目录 前言 现代Intel® CPU架构 指令集 CISC, RICS ... Intel各个时期的CPU微架构(microarchitecture)特点 P6 Family Microarchi ...

  9. JVM内存模型、指令重排、内存屏障概念解析

    在高并发模型中,无是面对物理机SMP系统模型,还是面对像JVM的虚拟机多线程并发内存模型,指令重排(编译器.运行时)和内存屏障都是非常重要的概念,因此,搞清楚这些概念和原理很重要.否则,你很难搞清楚哪 ...

最新文章

  1. eclipsse 关闭 方法提示_MacOS10.15.7关闭AppStore右上角小红标提示及系统更新右上角小红标提示的方法...
  2. mybatis的工作原理
  3. lintcode:Singleton 单例
  4. wget: unable to resolve host address “http”
  5. django返回指定html文件,Django返回HTML文件的实现方法
  6. android 退出应用,如何停止服务,Android 完全退出当前应用程序的四种方法
  7. 【LeetCode 剑指offer刷题】树题19:8 二叉树中序遍历的下一个结点
  8. asp.net调用ajax实例
  9. 【模板】线性筛法求素数
  10. poj 1753 Flip Game (高斯消元 + 枚举 自由变量)
  11. COS对象存储服务的使用
  12. 被逼无奈,沉默寡言的程序员也开始露脸拍视频了
  13. 未转变者服务器bug,未转变者攻略 unturned无敌BUG说明
  14. matlab中clear;close;clc的作用说明
  15. JavaScript系列之去掉字符串前后的空格
  16. IL汇编语言介绍(译)
  17. Perl 最佳实践(节选) --- 12
  18. AutoCAD Civil 3D创建点文件描述码(点特征码)集控制展点样式与特性
  19. 面试官问了四个问题,总结了4个经验
  20. c语言嵌套结构体数组,第22节 C语言结构体之结构体嵌套、结构体指针与结构体数组的代码实现...

热门文章

  1. python任务队列 http_基于Python开发的分布式任务队列:Celery
  2. android 中间按钮突出,Android 实现 按钮从两边移到中间动画效果
  3. 公司--As Imp的写法
  4. 公司--保存时验证数据是否保存重复
  5. java 之 面向对象
  6. 微信小程序打开红包的css_山海经攻略(微信小程序现金红包提现游戏)
  7. 用oracle用户登陆toad,配置Toad连接远程Oracle数据库
  8. 利用边缘灰度变化建模,来提高圆环直径求取精度
  9. 2021年春季学期-信号与系统-第五次作业参考答案-第五小题
  10. 智能车竞赛云端比赛第三天:一场在家具建材广场中的智能车比赛