CPU多核同步原语

这篇文章主要介绍了对称多核CPU体系(即SMP设计)中,用于内存(memory)同步的一些术语, 以及其原理。理解这些术语以及其后的原理,是理解多核CPU设计文档,以及一些在此基础上 制定出来的标准(例如,C++11的memory order约束)的基础。

在尝试理解多核CPU同步之前,本文的读者最好对CPU的执行,以及存储体系结构有一定 的了解。在下一节,本文会介绍一些强调的背景知识,但这些并不足以让没有相关技术 储备的读者理解本文。

本部分主要参考 Memory Barrier: a Hardware View for Software Hackers 部分的内容,如果希望了解更多的信息,请参考这篇论文。在这里有更多关于这个作者的论文汇总.

本文章是一系列汇总多核内存系统的一部分,希望能够坚持下去。

背景

本部分主要介绍一些背景知识。

程序顺序

我们首先介绍一下单核CPU的执行。对于单核CPU而言,非常重要的一个概念是program order, 即程序本身的顺序。无论是哪一种体系结构,program order是必须遵从的。

例如,初始化的时候:

int a, b;
a = b = 0;

对于下边的程序片段:

a = 1;
b = 1;
while(b == 0) {}
assert(a == 1);

没有任何一个程序员希望最后一个断言失败,因为从程序角度来看,变量a已经在断言 之前被赋值了。如果 单核 CPU的执行不遵从程序顺序,那也就无从在此基础上构建我们 的软件了[^1]。

[^1]: 实际上的确有一些平台,在多线程程序中,单独一个core可能不遵从程序顺序,这个在我们讲述Alpha CPU的时候会着重分析一下

原子操作

原子操作(atomic operation),即原子性地读取或者修改一个变量的操作。原子性体现在,对于变量的修改是原子性的,任何其他core都不会观察到对变量修改的中间状态。例如,如果对一个非原子的内存中的变量a加1,则在Load-Store体系中,可能需要:

lw r1, a
addi r1, r1, 1
sw a, r1

当一个core执行这段代码的时候,另一个core也可能在执行相同的代码。导致尽管两个core分别对a加了1,最终存回到memory中的a仍然只加了1,而没有加2.造成这样的原因是因为一个core对a的自加操作是非原子性的,其他的core有可能在此期间插入进来。原子操作则是从硬件实现上保证这一点。

在Load-Store体系中,任何对齐于数据结构本身的load和store一般都是原子操作,因为core对于这种数据结构的load和store仅需要一条指令就可以完成,其他core没有机会观察到中间状态。然而一条指令就可以完成只是原子操作的必要非充分条件,例如在非Load-Store体系中,举例来说,X86里,对内存中一个位置的自加操作只需要一条指令就可以完成,但是实际上core却需要执行载入-更改-写回三步,任何一步都可能被打断。

在Load-Store里,当我们提到原子操作的时候,一般指的是read-modify-write(即RMW)操作^4,即那些需要直接更改内存中数据的操作。因为在Load-Store中,典型的运算指令只能操作寄存器中的数据,而操作数据之前,必须用load将数据从内存载入,操作数据之后,必须用store将数据写出。因此,更改内存的操作在load-store中至少需要三条指令才可以完成。当需要RMW为原子操作的时候,就必须定义新的原子指令了。

原子操作是lock-free的多线程代码的基础。然而需要特意强调的一点就是,原子操作无法保证操作的顺序。原子操作只定义了对于本操作的原子性,而并未定义任何与之相关的顺序问题。 在lock-free多线程中,原子操作仍然要和memory barrier结合起来,仅仅依靠原子操作,是无法同步多个线程的。例如,对于如下的代码:

std::atomic_int a { 0 };
std::atomic_int b { 0 };
int c = 0;

core0执行以下代码:

b.fetch_add(2,std:memory_order_relaxed);
c = 3;
a.fetch_add(1, std:memory_order_relaxed);

同时core1执行以下代码:

while(a.load(std:memory_order_relaxed) == 0) {}
int result = b.load(std:memory_order_relaxed) + c;
assert(result == 5);

core1中的断言还是有可能失败。core0中对a的原子性赋值,以及core1中对a的原子性检查,并没有保证对原子变量b和非原子变量 c的赋值顺序。在core1中,result的可能结果包括0/2/3/5,任何一种都可能。

原子操作一般可以用于与顺序无关的地方,例如,多个core共享一个计数器,那么计数器本身需要定义为原子数据类型,所有core对这个计数器的更新(更新需要读取旧值,增加或者减少,然后写会新值,因此是RMW操作)都使用原子操作。这样,我们就不需要同步多个core之间的顺序,只需要保证在程序执行的最后计数器已经完成了所有core的更新操作就可以了。

缓存体系

现代CPU设计为了弥补CPU中执行部件与外部存储之间巨大的速度鸿沟,往往会引入多层( 典型的时两层,复杂的会有三到四层)缓存体系,即cache hierarchy。

多层cache的引入,保证了外部存储(即各种DDR内存)的速度提升不大(相比于CPU主频) 的前提下,CPU性能基本随着CPU core本身的提升而提升。然而,cache导致一个数据可以 在多个地方被缓存,这大大增加了在此基础上运行的程序出错的可能。这种数据的多处 缓存,可以从两个层面来考虑

  1. 上下级cache之间。例如,CPU core将值写入了L1 cache,然而内存中对应的数据并没有 得到更新。这种情况一般出现在单进程的程序控制其他非CPU的agent的情况,例如,CPU和 GPU之间的交互。解决这种问题的思路一般是程序显式或者隐式地清空相应的cache。显式的 途径包括发flush/invalidate命令,隐式的途径一般是直接将对应的内存空间标记为 non-cache,或者write-through.
  2. 同一级cache之间。例如,在一个4核处理器中,每个core都拥有自己的L1 cache。那么 一份数据可能在这4个L1 Cache中都有相应的拷贝。这种情况一般出现在多线程的程序 设计中。这种情况下,多线程程序需要显式提供必须的信息,以帮助SMP正确执行程序。 这也是本篇文章关注的焦点。

内存操作顺序

内存操作顺序是指从某个角度观察到的对于内存的读和写所发生的顺序。着重强调的一点是内存操作顺序并不唯一。例如在一个有core0和core1的CPU中,core0/core1各有着自己的内存操作顺序,这两个内存操作顺序不一定相同。

此外,还有一个全局内存操作顺序,即所谓的global memory order。global memory order可以是实际上存在于某个硬件实体上的顺序,例如CPU中的core0/core1(只有两个core)有自己的L1 cache,但是共享一个L2 cache,那么我们就可以认为global memory order就是L2 cache观察到的内存操作顺序。这里的global指的是core0/core1的范畴,如果我们引入其他DMA设备,这个global范畴就不再适用了。

全局内存操作顺序也可以是虚拟的顺序。例如上文提到的core0和core1如果使用某种cache一致性协议,例如,MESI,来保证L1 Cache的一致性,那么global memory order就是这些L1 cache中所观察到的一致性顺序。L1 cache如果由于某种原因暂时进入不一致状态时,我们就说导致这种不一致状态的load或者store操作并没有进入到global memory order中。

对于一个core而言,这个core所观察到的内存操作顺序不一定精确地符合之前所说的程序顺序,但是在这个core上,内存操作顺序必然有着和程序顺序相同的结果。例如,对于一个core 0,执行下面程序(a,b均初始化为0)

a = 1;
b = 2;

程序顺序要求a先赋值为1,然后b再赋值为2. 然而由于各种原因(compiler reorder, 超标量,预执行,cache miss等),这个core可能先将b赋值为2,然后才将a赋值为1. 内存操作顺序无法精确匹配程序顺序,但是这段代码执行完毕以后,a一定等于1,b也一定等于2,这就是所谓的 有着相同的结果 .

多线程程序中,每个线程所工作的core观察到不同的内存操作顺序,以及这些顺序与global memory order之间的差异,是导致可能多线程同步失败的一个非常重要的原因。Memory Barrier的引入,就是为了解决这个问题,引导core之间以及与global memory之间达成相同的内存操作顺序。

硬件平台

不同的硬件设计,会诞生不同的同步策略以及相对应的同步原语。在这里,我们着重分析一种比较简单的CPU memory hierarchy设计思路,并进一步在下一部分导出以下几种同步原语的原理:

  • smp_mb()
  • smp_wmb()
  • smp_rmb()

这三种同步原语是目前Linux Kernel中广泛(但并不是全部)使用的同步原语。

初步设计

在最初的设计中,我们的多核CPU有两个core, 分别是core 0和core 1。这两个core各有自己的L1 Cache,这两个Cache之间使用MESI协议来维护cache之间的一致性。

我们的硬件设计大概是这种结构:

+-------------+                  +-------------+   
      |             |                  |             |   
      |   core 0    |                  |   core 1    |   
      |             |                  |             |   
      +-^-------v---+                  +-^-------v---+   
        ^       v                        ^       v       
      +-^-------v---+                  +-^-------v---+   
      |             |                  |             |   
      |    cache    |                  |    cache    |   
      |             |                  |             |   
      +-------------+                  +-------------+   
             |                                |          
             |                                |          
             __________________________________
                            |
                            |
          +--------------------------------------+
          |                                      |
          |             Main Memory              |
          |                                      |
          +--------------------------------------+

现在,我们在core 0上执行如下程序(ab均初始化为0):

a = 1;
b = 2;

假设core 0的L1 Cache中没有a,但是有b。在core 0执行对a的赋值的时候,store操作会造成L1 Cache miss。此时,core 0必须stall一段时间,等待a的cache就位(可能是从core 1的cache 中过来,也可能是从memory中载入)。对于core 0而言,只有当store的数据进入到自己的L1 cache中才算完成。因此,为了保证顺序,在对a的store完成之前,core 0只能等待着^2.

这种store导致的stall是对性能的一种损害,因此,我们在这个设计中引入store buffer(有时候也叫做write buffer).

引入Store Buffer

Store Buffer的作用,说白了就是如果一个store发生的时候,如果对应的cache没有就位(cache可能不在,或者在,但是出于Share状态,无法更改),那么store buffer就先将这个store buffer住。如果一个core 的store进入了自己的store buffer,我们就认为这个store已经完成了,因为随后这个store buffer在cache就位的时候会自动写入到对应的cache中。

+-------------+                  +-------------+   
      |             |                  |             |   
      |   core 0    |                  |   core 1    |   
      |             |                  |             |   
      +-^-------v---+                  +-^-------v---+   
        ^       v                        ^       v       
        ^       v                        ^       v       
        ^     +-------+                  ^     +-------+ 
        ^     | store |                  ^     | store | 
        ^     |  buf  |                  ^     |  buf  | 
        ^     +-v-----+                  ^     +-v-----+ 
        ^       v                        ^       v       
        ^       v                        ^       v       
      +-^-------v---+                  +-^-------v---+   
      |             |                  |             |   
      |    cache    |                  |    cache    |   
      |             |                  |             |   
      +-------------+                  +-------------+   
             |                                |          
             __________________________________
                            |
                            |
          +--------------------------------------+
          |                                      |
          |             Main Memory              |
          |                                      |
          +--------------------------------------+

只引入store buffer来缓冲store是不够的,考虑如下一个程序:

a = 1;
b = 2;
assert(a == 1);

在执行断言的时候,需要load a的值,如果这个load发生在cache存在,但是出于Share状态无法更改的时候,那么这个load就会load到a的旧值0,导致断言失败。然而实际上我们在程序中先将a赋值为1了,断言从程序角度来讲不应该失败(仅仅core0修改a,不涉及其他core)。因此,这违背了程序顺序,硬件必须解决这个问题。

问题出现的根源在于,此时在core 0的视角,变量a有两份,一份位于store buffer中,是core 0修改a的过程。另一份则位于cache中,是a的旧值。如果只是简单的引入store buffer缓冲写,而不考虑这两份拷贝之间的相互关系,就会出现我们之前说的错误。解决这个错误的方法其实也很简单,我们在多级流水线CPU设计的时候就经常使用这种技术,forwarding。此时两个拷贝,显然是store buffer里保存的才是最新的数据。在这种情况下,core 0对于所有随后的load操作,必须先检查一下store buffer中是否有pending的store,如果有,那么就取对应pending的store的值,否则才可以考虑取cache中的值[^3]。

[^3]: 之所有cache中存在这个值,但是store还必须被buffer,是因为cache中这个值可能处于share状态,无法更改, store buffer必须等待cache进入可更改的Exclusive状态,才可以将pending的store更新到cache 中。

所有使用store buffer的设计,必须对应实现store buffer forwarding,因为如果不实现forwarding,单核程序上的执行就不再遵从程序顺序。有了store buffer forwarding之后,我们的涉设计就是这个样子的,注意store buffer与core之间的双向数据线,这是实现forawarding的地方:

+-------------+                  +-------------+   
      |             |                  |             |   
      |   core 0    |                  |   core 1    |   
      |             |                  |             |   
      +-^-------v---+                  +-^-------v---+   
        ^       v                        ^       v       
        ^       v                        ^       v       
        ^     +-------+                  ^     +-------+ 
        ^<<<<<| store |                  ^<<<<<| store | 
        ^>>>>>|  buf  |                  ^>>>>>|  buf  | 
        ^     +-v-----+                  ^     +-v-----+ 
        ^       v                        ^       v       
        ^       v                        ^       v       
      +-^-------v---+                  +-^-------v---+   
      |             |                  |             |   
      |    cache    |                  |    cache    |   
      |             |                  |             |   
      +-------------+                  +-------------+   
             |                                |          
             __________________________________
                            |
                            |
          +--------------------------------------+
          |                                      |
          |             Main Memory              |
          |                                      |
          +--------------------------------------+

store buffer的引入,有效隐藏了store可能带来的延迟。

引入Invalidate Queue

我们假设一个变量在cache中处于Share状态,如果一个core需要修改这个变量,那么这个core首先需要获取对这个变量的修改权,这可以通过invalidate其他core对应的cache来达到这一点。当变量在其他core中被invalidate掉,只存在于本core的cache的时候,本core就可以*自在地修改这个变量的值,而不用担心破坏cache的一致性了。

如果core 0需要修改很多变量的值,那么core 0当然需要为每一个变量向core 1发送invalidate,以保证core 0能够获取修改权。这个时候,实际上在我们CPU中发生的事情会是:core 0发送a变量invalidate, core 1收到a的invalidate命令,core 1执行变量a的invalidate(这可能很费时间),core 1回复core 0a的invalidte执行完毕(回复invalidate ack),core 1发送b变量的invaildate, and so on。在core 1回复给core 0 a的invalidate ack之前,core 0对a的更改只能pending在core 0自己的store buffer中。

实际上,当core 1收到了core 0对a的invalidate的时候,core 1就已经获取到信息,表明变量a对应的拷贝在core 1中已经失效了。core 1不必等到真正把cache中a所在的cache line给invalidate掉,再回复core 0 invalidate ack。实际上,core 1完全可以建立一个queue,将这个invalidate命令先丢入queue中,然后立马回复core 1 ack。这差不多就相当于core 0让core 1办点儿事情,并且让core 1干完活之后通知core 0一声,为了加速过程,core 1收到办事的请求后不去实际办事(例如实际去银行取钱啊之类的比较复杂的事情),而是先把要办的事情拿个小本本记下来,然后告诉core 0假装事情已经办完了。这么做的好处有两点:

  • 不会导致core 0出现不必的stall。core 0可能需要等到core 1干完事才能继续core 0下一条指令的执行,但是core 1现在假装立即回复core 0任务完成,core 0可以处理随后的事情而不用担心出什么错误(实际上肯定会出错,不过这就是另外一个话题了,我们随后会讨论这个问题)
  • core 1将办事记录在小本本上,也可以提供进一步优化的可能。例如,可能core 0要core 1去银行取钱,然后去顺路的菜市场买菜。如果core 1每次收到命令执行完毕在看下一条命令的话,很可能走冤枉路。现在core 1如果注意到小本本上记录的银行和菜市场顺路,一趟就能搞定事情。在硬件中,这表明core 1潜在地可以合并相关的invalidate,例如,如果几个invalidate的地址在一个cache line内部,core 1完全可以只做一次invalidation,而不是为每个变量都做一次invalidation。

小结

到现在,我们的硬件平台已经出来了。在多核中,每个core都有自己的cache。围绕这个cache,有一个store buffer用以缓冲 本core 的store操作。此外,还有一个invalidate queue,用以排队 其他core 的invalidate command。这个平台是我们随后讨论的同步原语的基础。

+-------------+                  +-------------+   
      |             |                  |             |   
      |   core 0    |                  |   core 1    |   
      |             |                  |             |   
      +-^-------v---+                  +-^-------v---+   
        ^       v                        ^       v       
        ^       v                        ^       v       
        ^     +-------+                  ^     +-------+ 
        ^<<<<<| store |                  ^<<<<<| store | 
        ^>>>>>|  buf  |                  ^>>>>>|  buf  | 
        ^     +-v-----+                  ^     +-v-----+ 
        ^       v                        ^       v       
        ^       v                        ^       v       
      +-^-------v---+                  +-^-------v---+   
      |             |                  |             |   
      |    cache    |                  |    cache    |   
      |             |                  |             |   
      +-------------+                  +-------------+   
             |                                |          
    +------------------+             +------------------+
    | invalidate queue |             | invalidate queue |
    +------------------+             +------------------+
             |                                |          
             |                                |          
             __________________________________
                            |
                            |
          +--------------------------------------+
          |                                      |
          |             Main Memory              |
          |                                      |
          +--------------------------------------+

Memory Barrier在多线程中的应用

Store Buffer对于store顺序的影响

在我们的硬件结构中,使用store buffer来加速store的实现,并用store buffer forwarding技术保证当前core的load总是正确load到正确的数据。如果我们在一个core上执行如下程序:

a = 1;
b = 2;
assert(a == 1 && b == 2);

显然,在仅仅单核执行的情况下最后的断言失败永远不可能发生。对于这个core本身而言,store的顺序是天然保证的。

然而,由于store buffer的存在,core所观察到的store顺序可能不等同于global memory顺序。例如,在我们的体系中,global memory order就是两个core的cache达成coherency时的顺序。

假如core 0执行对于ab分别的赋值,也就是上边的程序。由于某种原因,a所在的cache出现了miss,因此,a的store不得不进入store buffer。之后,对于b的store命中了core 0的一条Exclusive的cache line,因此对于b的store迅速进入了cache中。过了一段时间之后,对a的cache line才准备好,此时,在store buffer pending的a的store才进入了core 0的cache中。从global memory order观察,对于b的store发生在a的store之前。这种core本身所观察到的store的顺序与global memory的order之间的不一致,很可能导致多核程序的同步失败,假如此时core 1执行以下代码:

while (b != 2) {}
assert(a == 1);

core 1的本意是一直等待b2,即b的store完成,这个时候core 1根据core 0上的程序顺序,判断在b之前的store也应该完成了,因此,随后的断言将不会发生。但实际上,随后的断言很可能发生的。这里的同步假设犯的错误时,没有意识到所谓的 程序顺序 只对单个core有效,而对多个core之间没有意义。core 0上的程序顺序保证对a的store一定发生在对b的store之前,然而这个保证只适用于core 0,因为程序实在这个core上执行的,而且这个所谓的发生,也只是从core 0自己观察角度来看的。

在实际中,很可能发生这种情况。core 0完成对b的store(进入core 0的cache line),并且对a的store仍然在core 0自己的store buffer的时候,core 1执行第一个while(b != 2) {}语句,从core 1自己的cache中通过MESI协议load到了b的最新值,所以core 1会终止循环,进入assert(a == 1)断言部分。此时由于core 0对a的store仍然在core 0自己的store buffer中,所以core 1从cache中load到了a的旧值,导致断言失败。

我们可以看到,store buffer的引入,导致了进行store动作的core所观察到的store序列完成的顺序,与这些store最终进入global memory的顺序出现了不一致。这种不一致行为,导致了依赖于global memory order进行同步的多线程程序无法正确同步彼此之间的store状态。

那么,这个问题该如何解决呢?一种可行的思路是强制保证所有store的顺序在单个core之间和global memory之间的一致。例如,如果store buffer中有未完成的store,那么随后的store要么等待store buffer清空才可以进行(这样一来store buffer带来了对store的加速提升就大打折扣了),或者也可以将这个store塞入store buffer中,以保证store buffer序列化作用于global memory中。x86 CPU就采用了这种思路,保证一个core的store顺序天然地等同于这些store作用于global memory的顺序。所以,上述两个线程的程序在x86平台上是永远不会出现断言失败的。

这种思路最大的好处就是简化了软件模型,而将复杂度放置到了硬件这边。事实上,除非进入临界区,否则我们是不需要这么strong的order的。另一种可行的思路是放任这种观察到的store order不匹配的出现,同时提供一种方法,当应对需要保证顺序的情况。这种方法,就是我们这里所说的write memory barrier,即wmb().

write memory barrier在硬件上实现的思想类似于强制序列化store,但是只在需要的时候发生。如果core执行到write memory barrier,这个时候在store buffer中所有pending的store动作都会被mark上,直到这些被标注的store都进入到global memory之后,随后的store才能进入global memory order中。因此,write memory barrier保证了这个barrier前后的顺序。至于wmb之前部分的那些store,以及之后部分store完成的顺序是否与global memory order匹配,则不关心。

例如,假设我们在core 0上分别对变量a, b, c, d执行store,并且在bc之间插入了write memory barrier,那么从global memory观察到以下这些store完成的顺序是完全合法的:

  • a, b, c, d
  • b, a, c, d
  • a, b, d, c
  • b, a, d, c

换言之,这个wmb只保证在global memory order中a``b的store一定会在c``d之前完成,至于a``b内部,或者c``d内部完成顺序,则没有规定。

invalidate queue对load顺序的影响

分析完store buffer对store顺序的影响,我们接下来看一下我们设计的invalidate queue对于load会有什么影响。

Invalidate queue的引入,使得一个core可以快速返回给别的core所发出的invalidate请求,然后再在合适的时机执行invalidate操作。因此,潜在地,这个core的load操作就可能拿到本应该被invalidte的数据,造成这个core所观察到的数据被修改的order与实际的global memory order出现了不匹配。还是以我们之前的例子来看:

core 0执行:

a = 1;
wmb();
b = 2;

core 1则执行:

while(b != 2) {};
assert(a == 1);

core 0中的wmb()保证了core 0中的store顺序(先ab)一定正确地匹配global memory的顺序,即从global memory来看,也一定是先ab。这样足够吗?因为core 0的wmb()只保证这个顺序进入了global memory order,但是core 1观察到的顺序不一定匹配global memory order,core 1上的断言仍然有可能失败。这是怎么发生的呢,让我们来分析一下。

当core 0执行对a的赋值的时候,core 0需要发送一个invalidate命令给core 1,以保证core 0获取对a的修改权。由于core 1的invalidate queue的存在,这个invalidate命令很快就得到了来自core 1的ack,因此,core 0可以安心地修改a了。当core 0执行完毕对ab的修改之后,core 1的while循环就可以跳出来,因为此时core 1已经可以观察到对于b的更改。从global memory order来看,既然b的store已经完成,那么core 0对a的更改(两者之间有wmb()保证)则一定能够在global memory中反映出来。但是,由于invalidate queue的存在,core 1对于a的load很可能会命中core 1自己的一个cache line,而这个cache line本应该被invalidate queue中的一个invalidate命令标记无效的。由于invalidate queue的存在,导致core 1观察到的memory order与global memory order不匹配,因此,随后的断言可能会失败。

如何解决这个问题呢?一种可行的思路,就是从硬件直接解决,每次load的时候必须保证invalidate queue被清空,这样可以保证load的strong order,在这里我们就不再考虑这种方法了。另外一种,就是仿照wmb()的定义,加入rmb()约束。rmb()给我们的invalidate queue加上标记。当一个load操作发生的时候,之前的rmb()所有标记的invalidate命令必须全部执行完成,然后才可以让随后的load发生。这样,我们就在rmb()前后保证了load观察到的顺序等同于global memory order。

现在,我们的程序变成了这个样子, core 0执行:

a = 1;
wmb();
b = 2;

core 1则执行:

while(b != 2) {};
rmb();
assert(a == 1);

core 0的wmb()保证store的顺序正确进入global memory order,core 1的rmb()保证core 1观察到的load顺序等同于global memory order。通过这一点,core 0和core 1之间建立了有效的同步机制,因此,core 1中的断言将永远不会失败。

带dependency-check的rmb()

为了保证load的书序,rmb()需要标记invalidate queue中所有pending的invalidate,直到这些pending的invalidate完成才进行load。这样自然带来了性能上的损失。其实仔细想一下,为了保证load总是能够获取到global memory中最新的数据,我们只需要检查一下invalidate queue中是否存在于与我们load的地址相匹配的invalidate命令,只标记并等待这些与之有依赖关系的invalidate command就足够了。这种,就是带有dependency-check的rmb(),也就是一般说的 data dependency barrier .

Data dependency barrier是一种弱形式的rmb(),这种barrier只保证barrier之后的load操作一定能够观察到barrier之前与之有依赖关系的那些load操作的结果。

一般而言,data depdency barrier是不需要程序显式指定的,因此从单独的core上来看,带有依赖关系的load必须遵守程序顺序。例如下面一个例子:

int arr[100] = {};
int *p = &arr[0];

while (b == 0) {}
int c = p[b];

随后在c的赋值中对b的引用,一定要等到while循环获取了b的值才行,因此p[b]与之前的while(b == 0) {}之间存在依赖关系。这种依赖关系即使是在单核程序上也必须被保证的。

但是,凡事就怕特殊。有这么一种平台,在单核的时候的确能够天然保证带有数据依赖关系的load之间的order。但是,如果运行在多核环境下,由于缺乏 自动 检测invalidate queue的dependency的能力,带有data dependency的load可能会出现意料不到的结果,这种平台就是Alpha架构。感兴趣的同学可以用Alpha + data dependency搜一下相关的背景介绍。主流的平台除了Alpha,都是可以天然保证data dependency的。

硬件实现对load strong order的考量

为了保证读取的顺序,一种可行的思路是仿造store buffer forwarding的思路,当core load数据的时候,先检查一下需要load的数据是否有pending的invalidate,如果有的话,就等待对应的invalidation完成再做load动作。这样就可以保证load的core观察到的内存操作顺序始终等同于global memory order。

在实际的设计中,硬件可以选择实现这样的机制,也可以选择不实现,具体理由如下:

  1. invalidate queue的check需要消耗资源和时间。与store buffer forwarding一样,这也需要check数据。然而invalidate queue服务于load操作而store buffer服务于store操作。在程序中,一般认为load操作的数目是远远超过store的。如果程序显式地用data memory barrier要求进行check,这种资源和时间的消耗就是必须的。如果程序没有显式要求,那么为此带来的性能的损失和功耗的提升,就需要仔细考虑了。
  2. 如果不实现invalidate queue的forwarding,在单独的core上也不会违背程序 顺序的约束。而如果不实现store buffer forwarding的话,在单独的core上就会出现违背程序顺序的情况。因此,store buffer fowarding是必须实现的,但是invalidate queue的forwarding则没有这个约束。

所以,如果硬件中有invalidate queue,为load实现硬件级别的coherent就成为了可选项,取决于硬件设计者对性能和实现复杂度的评估。

小结

为了提高处理器的性能,我们分别在SMP中引入了store buffer(以及对应实现store buffer forwarding)和invalidate queue. store buffer的引入导致core上的store顺序可能不匹配于global memory的顺序,对此,我们需要使用wmb()来解决。invalidate queue的存在导致core上观察到的load顺序可能与global memory order不一致,对此,我们需要使用rmb()来解决。

由于wmb()和rmb()分别只单独作用于store buffer和invalidate queue,因此这两个memory barrier只保证了store/load的顺序。对于wmb()而言,前后的load在global memory中仍然可能out-of-order。同样,对于rmb()而言,前后的store到global memory中依然可能out-of-order。因此,我们引入了mb()的概念。所谓的mb(),就是rmb()和wmb()的结合,同时标记store buffer和invalidate queue。在mb()前后,所有的load/store操作都必须拥有与global memory一致的顺序。

其他类型的同步原语

除了我们在这里详细介绍的read/write memory barrier之外,还存在其他的同步原语,例如release-acquire,以及LL-SC等。有机会我们再介绍。

参考资料

C++ Concurrency in Action

Arm 体系结构资料

RISC V spec

上一篇:es6


下一篇:元祖