转载至http://blog.sina.com.cn/s/blog_7d1968e20102xarj.html 1. 缓存 在现代的CPU(大多数)上,所有的内存访问都需要通过层层的缓存来进行。也有些例外,比如,对映射成内存地址的I/O口、写合并内存,这些访问至少绕开这个流程的一部分。 CPU的读/写(以及去指令)单元正常情况下甚至都不能直接与内存进行访问-这是物理结构决定的;CPU都没有管脚直接连到内存。相反,CPU和一级缓存(L1 Cache)通讯,而一级缓存才能和内存进行通讯。大约20年前,一级缓存可以直接和内存传输数据。如今,更多级别的缓存加入到设计中,一级缓存不能和内存直接通讯了,而二级缓存才可以。或者更多,明白意思就行。 缓存是分“段”(line)的,一个段对应一块存储空间,大小是32、64、128字节。每个缓存段知道自己对应什么范围的物理内存地址,并在本文中,我不打算区分物理上的换成段和它所代表的内存,这听起来有点草率,但是为了方便起见,还是请熟悉这种提法。集体说,当我提到“缓存段”的时候,我就是指一段和缓存大小对齐的内存(本质用途是缓存),不关心里面的内容是否真正被缓存进去(就是说保存在任何级别的缓存中)了。 当CPU看到一条读内存指令时,它会把内存地址传给数据缓存(或可戏称为L1D$)。一级数据缓存会检查它是否有这个内存地址对应的缓存段。如果没有,它会把整个缓存段从内存(或者更奥一级的缓存,如果有的话)中加载进来。是的,一次加载整个缓存段,这是基于这样一个假设:内存访问倾向本地化,如果我们当前需要某个地址的数据,那么很肯我们马上就要访问它的邻近地址。一旦缓存段被加载进来,都指令就可以正常进行读取。 如果我们只处理读操作,那么事情会很简单,因为所有级别的缓存都遵循一下定律,我称之为:

基本定律:在任意时刻,任意级别的中缓存段的内容,等同于对应内存中的内容。

一旦我们允许写操作,事情就变得复杂一点了。这里有两种基本的写模式:真写(write-throug)和回写(write-back)。 直写更简单一点:我们通过本级缓存,直接把数据写到下一级缓存(或直接到内存)中,如果对应的段被缓存了,我们同时更新缓存中的内容(甚至直接丢弃),就这么简单。这也遵守前面的定律:缓存中的段永远和它对应的内存内容匹配。 回写模式就有些复杂了。缓存不会立即把写操作传递到下一级,而是仅修改本级缓存中的数据,并且把对应缓存段标记为脏段。脏段会触发回写,也就是把里面的内容写到对应的内存或下一级缓存中。回写后,脏段又变干净了。当一个脏段被丢弃的时候。总是先要进行一次回写。回写遵循的规律有些不同。

回写定律:当所有的脏段被回写后,任意级别缓存中的缓存段内容,等同于它对应的内存中的内容。

换句话说,回写模式的定律中,我们去掉了“在任意时刻”这个修饰语,代之以弱化一点的条件:要么缓存段的内容和内存一直,要么缓存段中的内容最终要写回到内存中(对于脏缓存段来说)。 直接模式更简单,但是回写模式有它的优势:它能过滤掉对同一地址的反复写操作,并且,如果大多数缓存段都在回写模式下工作,那么系统经常可以一下子写一大片内存,而不是分成小块来写,前者的效率更高。 【注】回写满足最终一致性,只要没有其他系统试图读取真正的内存,那么就可以延迟回写,使得写操作不那么频繁。 有些(大多数是比较老的)CPU只使用直写模式,有些只使用回写模式,还有一些,一级缓存使用直写而二级缓存使用回写。这样做虽然在一级和二级缓存之间产生了不必要的数据流量,但二级缓存和更低级缓存或内存之间任然保留了回写的优势。我想说的是,这里涉及到一系列的取舍问题,且不同的设计有不同的解决方案。没人规定二级缓存的大小必须一致。举个例子,我们会看到有CPU的一级缓存是32字节,而二级缓存却有128字节。 2.一致性协议 (Coherency protocols) 只要系统只有一个CPU核在工作,一切都没问题。如果有多个核,每个核又都有自己的缓存,那么我们就遇到问题了:如果某个CPU缓存段中对应的内存内容被另外一个CPU偷偷修改了,会发生什么? 好吧,答案很简单:什么也不会发生。这很糟糕。因为如果一个CPU缓存了某块内存,那么在其他CPU修改这块内存的时候,我们希望得到通知。我们拥有多组缓存的时候,真的需要它们保持同步。或者说,系统的内存在各个CPU之间无法做到与生俱来的同步,我们实际上是需要一个大家都能遵守的方法来达到同步的目的。 注意,这个问题的根源是我们拥有多组缓存,而不是多个CPU核。我们也可以这样解决问题,让多个CPU核共用一组缓存:也就是说只有一块一级内存,所有处理器都必须共用它。在每一个指令周期,只有一个幸运的CPU能通过一级缓存做内存操作,运行它的指令。 这本身没问题。唯一的问题就是太慢了,因为这下处理器的时间都花在排队等待使用一级缓存了(并且处理器会做大量的这种操作,至少每个读写指令都要做一次)。我指出这一点是因为它表明了问题不是由多核引起的,而是由多缓存引起的。我们知道了只有一组缓存也能工作,只是太慢了,接下来最好就是能做到:使用多组缓存,但使它们的行为看起来就像只有一组缓存那样。缓存一致性协议就是为了做到这一点而设计的。就像名称所暗示的那样,这类协议就是要使多组缓存的内容保持一致。 【注】如果只有一级缓存,读操作也只能通过处理器访问一级缓存,远不如在处理器拥有自己的二级缓存来的快。 缓存一致性协议有多种,但是你日常处理的大多数计算机设备都使用的都属于”窥探(snooping)“协议,这也是我这里要讲的。(还有一种叫”基于目录的(directory-based)“协议,这种协议的延迟性较大,但是在拥有很多个处理器的系统中,它有更好的可扩展性。) ”窥探“背后的基本思想是,所有内存传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线: 缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(arbitrate):同一个指令周期中,只有一个缓存可以读写内存。窥探协议的思想是,缓存不仅仅在做内存传输的时候才和总线打交道,而是不停地在窥探总线上发生的数据交换,跟踪其他缓存在做什么。所以当一个缓存代表它所属的处理器去读写内存时,其他处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器-写内存,其他处理器马上就知道这块内存在它们自己的缓存中对应的段已经失效。 【注】MESI协议就是按照窥探思想设计的,一旦发现一个处理器持有独占(E)状态或已修改状态,就会导致其他处理器持有同样主存的相应缓存立即失效。而一个处理器试图读取该缓存段,那么需要将E/M 状态转换成共享S状态,如果修改了缓存数据,需要立即回写至主存。 在直写模式下,这是很直接的,因为写操作一旦发生,它的效果马上会被”公布“出去。但是如果混着回写模式,就有问题了因为有可能在写指令执行过后很久,数据才会被真正回写到物理内存中-在这段时间内,其他处理器的缓存也可能会傻乎乎地去写同一块内存地址,导致冲突。在回写模型中,简单把内存写操作的信息广播给其他处理器是不够的,我们需要做的是,在修改本地缓存之前,就要告知其他处理器。搞懂了细节,就找到了处理回写模式这个问题的最简单方案,我们通常叫做MESI协议(MESI是Modified、Exclusive、Shared、Invalid的首字母缩写,代表四种缓存状态,下面的译文代表单个字母相应的状态)。 3、MESI以及衍生协议 本节叫做”MESI以及衍生协议“,是因为MESI衍生了一系列紧密相关的一致性协议。我们先从原生的MESI协议开始:MESI是四种缓存段状态的首字母缩写,任何多核系统中的缓存段都处于这四种状态之一。我将以相反的顺序逐个讲解,因为这个顺序更合理:

  • 失效(Invalid)缓存段,要么已经不在缓存中,要么它的内容已经过时。为了达到缓存的目的,这种状态的段将会被忽略。一旦缓存段被标记为失败,那效果就等同于它从来没被加载到缓存中。
  • 共享(Shared)缓存段,它是和主内存内容保持一致的一份拷贝,在这种状态下的缓存段只能被读取,不能被写入。多组缓存可以同时拥有针对同一内存地址的共享缓存段,这就是名称的由来。
  • 独占(Exclusive)缓存段,也是和主内存内容保持一致的一份拷贝。区别在于,如果一个处理器持有了某个E状态的缓存段,那其他处理器就不能同时拥有它,所以叫”独占“。这意味着,如果其他处理器原来也持有同一段换成段,那么它会马上变成”失败“状态。
  • 已修改(Modified)缓存段,属于脏段,它们已经被所属的处理器修改了。如果一个段处于已修改状态,那么它在其他处理器缓存中的拷贝马上会变成失效状态,这个规律和E状态一样。此外,已修改缓存段如果被丢弃或标记为失败,那么先要把它的内容回写到内存中-这和回写模式下常规的脏段处理方式一样。 如果把以上这些状态和单核系统中回写模式的缓存做对比,你会发现I、S和M状态已经有对应的概念:失效/未载入、干净以及脏的缓存段。所以这里的新知识只有E状态,代表独占式访问。这个状态解决了“在我们开始修改某块内存之前,我们需要告诉其他处理器”这一问题:只有当缓存段处于E或M状态时,处理器才能去写它,也就是说只有这两种状态下,处理器是独占这个缓存段的。当处理器想写某个缓存段时,如果它没有独占权,它必须先发送一条“我要独占权”的请求给总线,这会通知其他处理器,把它们拥有的同一缓存段的拷贝失效(如果它们有的话)。只有在获得独占权后,处理器才能开始修改数据——并且此时,这个处理器知道,这个缓存段只有一份拷贝,在我自己的缓存里,所以不会有任何冲突。

反之,如果有其他处理器想读取这个缓存段(我们马上能知道,因为我们一直在窥探总线),独占或已修改的缓存段必须先回到“共享”状态。如果是已修改的缓存段,那么还要先把内容回写到内存中。

MESI协议是一个合适的状态机,既能处理来自本地处理器的请求,也能把信息广播到总线上。我不打算讲更多关于状态图的细节以及不同的状态转换类型。如果你感兴趣的话,可以在关于硬件架构的书中找到更多的深度内容,但对于本文来说,讲这些东西有点过了。作为一个软件开发者,你只要理解以下两点,就大有可为:

第一,在多核系统中,读取某个缓存段,实际上会牵涉到和其他处理器的通讯,并且可能导致它们发生内存传输。写某个缓存段需要多个步骤:在你写任何东西之前,你首先要获得独占权,以及所请求的缓存段的当前内容的拷贝(所谓的“带权限获取的读(Read For Ownership)”请求)。

第二,尽管我们为了一致性问题做了额外的工作,但是最终结果还是非常有保证的。即它遵守以下定理,我称之为:

MESI定律:在所有的脏缓存段(M状态)被回写后,任意缓存级别的所有缓存段中的内容,和它们对应的内存中的内容一致。此外,在任意时刻,当某个位置的内存被一个处理器加载入独占缓存段时(E状态),那它就不会再出现在其他任何处理器的缓存中。

注意,这其实就是我们已经讲过的回写定律加上独占规则而已。我认为MESI协议或多核系统的存在根本没有弱化我们现有的内存模型。

好了,至此我们(粗略)讲了原生MESI协议(以及使用它的CPU,比如ARM)。其他处理器使用MESI扩展后的变种。常见的扩展包括“O”(Owned)状态,它和E状态类似,也是保证缓存间一致性的手段,但它直接共享脏段的内容,而不需要先把它们回写到内存中(“脏段共享”),由此产生了MOSEI协议。还有MERSI和MESIF,这两个名字代表同一种思想,即指定某个处理器专门处理针对某个缓存段的读操作。当多个处理器同时拥有某个S状态的缓存段的时候,只有被指定的那个处理器(对应的缓存段为R或F状态)才能对读操作做出回应,而不是每个处理器都能这么做。这种设计可以降低总线的数据流量。当然你可以同时加入R/F状态和O状态,或者更多的状态。这些都属于优化,没有一种会改变基本定律,也没有一种会改变MESI协议所确保的结果。

我不是这方面的专家,很有可能有系统在使用其他协议,这些协议并不能完全保证一致性,不过如果有,我没有注意到它们,或者没有看到有什么流行的处理器在使用它们。所以为了达到我们的目的,我们真的就可以假设一致性协议能保证缓存的一致性。不是基本一致,不是“写入一会儿后才能保持一致”——而是完全的一致。从这个层面上说,除非硬件有问题,内存的状态总是一致的。用技术术语来说,MESI以及它的衍生协议,至少在原理上,提供了完整的顺序一致性(sequential consistency),在C++ 11的内存模型中,这是最强的一种确保内存顺序的模型。这也引出了问题,为什么我们需要弱一点的内存模型,以及“什么时候会用到它们”?

四、内存模型 不同的体系结构提供不同的内存模型。到本文写作的时候为止,ARM和POWER体系结构的机器拥有相对较弱的内存模型:这类CPU在读写指令重排序(reordering)方面有相当大的自由度,这种重排序有可能会改变程序在多核环境下的语义。通过“内存屏障(memory barrier)”,程序可以对此加以限制:“重排序操作不允许越过这条边界”。相反,x86则拥有较强的内存模型。

我不打算在这里深入到内存模型的细节中,这很容易陷入堆砌技术术语中,而且也超出了本文的范围。但是我想说一点关于“他们如何发生”的内容——也就是,弱内存模型如何保证正确性(相比较于MESI协议给缓存带来的顺序一致性),以及为什么。当然,一切都归结于性能。

规则是这样的:如果满足下面的条件,你就可以得到完全的顺序一致性:第一,缓存一收到总线事件,就可以在当前指令周期中迅速做出响应。第二,处理器如实地按程序的顺序,把内存操作指令送到缓存,并且等前一条执行完后才能发送下一条。当然,实际上现代处理器一般都无法满足以上条件:

· 缓存不会及时响应总线事件。如果总线上发来一条消息,要使某个缓存段失效,但是如果此时缓存正在处理其他事情(比如和CPU传输数据),那这个消息可能无法在当前的指令周期中得到处理,而会进入所谓的“失效队列(invalidation queue)”,这个消息等在队列中直到缓存有空为止。

· 处理器一般不会严格按照程序的顺序向缓存发送内存操作指令。当然,有乱序执行(Out-of-Order execution)功能的处理器肯定是这样的。顺序执行(in-order execution)的处理器有时候也无法完全保证内存操作的顺序(比如想要的内存不在缓存中时,CPU就不能为了载入缓存而停止工作)。

· 写操作尤其特殊,因为它分为两阶段操作:在写之前我们先要得到缓存段的独占权。如果我们当前没有独占权,我们先要和其他处理器协商,这也需要一些时间。同理,在这种场景下让处理器闲着无所事事是一种资源浪费。实际上,写操作首先发起获得独占权的请求,然后就进入所谓的由“写缓冲(store buffer)”组成的队列(有些地方使用“写缓冲”指代整个队列,我这里使用它指代队列的一条入口)。写操作在队列中等待,直到缓存准备好处理它,此时写缓冲就被“清空(drained)”了,缓冲区被回收用于处理新的写操作。

这些特性意味着,默认情况下,读操作有可能会读到过时的数据(如果对应失效请求还等在队列中没执行),写操作真正完成的时间有可能比它们在代码中的位置晚,一旦牵涉到乱序执行,一切都变得模棱两可。回到内存模型,本质上只有两大阵营:

在弱内存模型的体系结构中,处理器为了开发者能写出正确的代码而做的工作是最小化的,指令重排序和各种缓冲的步骤都是被正式允许的,也就是说没有任何保证。如果你需要确保某种结果,你需要自己插入合适的内存屏障——它能防止重排序,并且等待队列中的操作全部完成。

使用强一点的内存模型的体系结构则会在内部做很多记录工作。比如,x86会跟踪所有在等待中的内存操作,这些操作都还没有完全完成(称为“退休(retired)”)。它会把它们的信息保存在芯片内部的MOB(“memory ordering buffer”,内存排序缓冲)。x86作为部分支持乱序执行的体系结构,在出问题的时候能把尚未“退休”的指令撤销掉——比如发生页错误(page fault),或者分支预测失败(branch mispredict)的时候。我已经在我以前的文章“好奇地说”中提到过一些细节,以及和内存子系统的一些交互。主旨是x86处理器会主动地监控外部事件(比如缓存失效),有些已经执行完的操作会因为这些事件而被撤销,但不算“退休”。这就是说,x86知道自己的内存模型应该是什么样子的,当发生了一件和这个模型冲突的事,处理器会回退到上一个与内存模型兼容的状态。这就是我在以前另一篇文章中提到的“清除内存排序机(memory ordering machine clear)”。最后的结果是,x86处理器为内存操作提供了很强的一致性保证——虽然没有达到完美的顺序一致性。

无论如何,一篇文章讲这么多已经够了。我把它放在我的博客上。我的想法是将来的文章只要引用它就行了。我们看效果吧。感谢阅读! MESI协议带来的问题和解决之道 转发至http://www.importnew.com/10589.html 在还没有说到细节时,我们知道消息的传递是需要时间的,它使得状态切换有更多的延迟。重要的是我们也需要意识到某些状态的切换需要特殊的处理,可能会阻塞处理器。这些都将会导致各种各样的稳定性和性能问题。

存储缓存(Store Bufferes) 如果你需要对一个在缓存中的共享的变量进行写入,你需要发送一个失效(Invalidate)消息给它的所有持有者,并且等待它们的确认。处理器在这段时间间隔内会阻塞,这是一个不爽的事情,因为这个时间的要求比普通执行一个指令要长得多。

在现实生活中,缓存条目不只包含一个变量。这个被划分出来的单元是一个缓存链,通常包含多于一个变量,并且很多是64字节大小的。

它会导致有趣的问题,例如缓存竞争

为了避免这种时间的浪费,Store Bufferes被引入使用。处理器把它想要写入到主存的值写到缓存,然后继续去处理其他事情。当所有失效确认(Invalidate Acknowledge)都接收到时,数据才会最终被提交。

有人会想到这里有一些隐藏的危险存在。简单的一个就是处理器会尝试从存储缓存(Store buffer)中读取值,但它还没有进行提交。这个的解决方案称为Store Forwarding,它使得加载的时候,如果存储缓存中存在,则进行返回。

第二个陷阱是:保存什么时候会完成,这个并没有任何保证。考虑一下下面的代码:

void executedOnCpu0() {
    value = 10;
    finished = true;
}
 
void executedOnCpu1() {
    while(!finished);
    assert value == 10;
}

试想一下开始执行时,CPU 0保存着finished在Exclusive状态,而value并没有保存在它的缓存中。(例如,Invalid)。在这种情况下,value会比finished更迟地抛弃存储缓存。完全有可能CPU 1读取finished的值为true,而value的值不等于10。

这种在可识别的行为中发生的变化称为重排序(reordings)。注意,这不意味着你的指令的位置被恶意(或者好意)地更改。

它只是意味着其他的CPU会读到跟程序中写入的顺序不一样的结果。

无效队列 执行失效也不是一个简单的操作,它需要处理器去处理。另外,存储缓存(Store Buffers)并不是无穷大的,所以处理器有时需要等待失效确认的返回。这两个操作都会使得性能大幅降低。为了应付这种情况,引入了失效队列。它们的约定如下:

对于所有的收到的Invalidate请求,Invalidate Acknowlege消息必须立刻发送 Invalidate并不真正执行,而是被放在一个特殊的队列中,在方便的时候才会去执行。 处理器不会发送任何消息给所处理的缓存条目,直到它处理Invalidate 也正是那些优化的情况会导致这种跟直觉不符的结果。让我们看回代码,假设CPU 1存有Exclusive状态的value。这里有一张图表,表示其中一种可能的执行情况: 同步是很简单容易,不是吗?问题在于steps (4) – (6)。当CPU 1在(4)接收到Invalidate时,它只是把它进行排列,并没有执行。CPU 1在(6)得到Read Response,而对应的Read在(2)之前就被发送。尽管这样,我们也没有使value失效,所以造成了assertion的失败。如果那个操作早点执行就好了。但,唉,这该死的优化搞坏了所有事情!但从另一方面考虑,它给予了我们重要的性能优化。

那些硬件工程师没办法提前知道的是:什么时候优化是允许的,而什么时候并不允许。这也是他们为什么把这个问题留给我们。它同时也给予我们一些小东西,标志着:“单独使用它很危险!用这个!”

硬件内存模型 软件工程师在出发和巨龙搏斗时被授予的魔法剑并不是真正的剑。同样,那些搞硬件的家伙给我们的是写好的规则。他们描述着:当这个(或其他)处理器执行指令时,处理器能够看见什么值。我们能够像符咒一样把他们分类成Memory Barriers。对于我们的MESI例子,它描述如下:

Store Memory Barrier(a.k.a. ST, SMB, smp_wmb)是一条告诉处理器在执行这之后的指令之前,应用所有已经在存储缓存(store buffer)中的保存的指令。

Load Memory Barrier (a.k.a. LD, RMB, smp_rmb)是一条告诉处理器在执行任何的加载前,先应用所有已经在失效队列中的失效操作的指令。

因此,这两个方法可以防止我们之前遇到的两种情况。我们应该使用它:

void executedOnCpu0() {
    value = 10;
    storeMemoryBarrier(); // Mighty Spell!
    finished = true;
}
void executedOnCpu1() {
    while(!finished);
    loadMemoryBarrier(); // I am a Wizard!
    assert value == 10;
}

一次编写,处处运行 上面的那些缓存一致性协议,memory barriers,内存清除(dropped caches)和类似的东西看起来都是恶心的平台相关的东西。Java开发人员不应该关心这些东西。毕竟Java内存模型没有重排序的概念。