为什么需要内存屏障

为什么需要内存屏障

本文主要以perfbook的附录C "Why Memory Barriers"为基础,来总结一下个人对Memory Barriers的一些认识。

随着CPU频率提升达到极限,多核CPU早已取代单核CPU成为发展主流。程序开发也因此发生了很大的变化,再也不能单纯地依靠CPU频率的提升来加快软件的运行速度了。Herb Sutter在很多年前便高呼"The Free Lunch Is Over"。

计算机中,程序的执行是通过CPU取得指令和数据来进行运算完成的。显然,计算机最主要的部件少不了CPU和存储介质。然而,存储器存取速度与CPU速度之间有着数量级的差异,而且存储器容量、价格和速度之间总是顾此失彼。一般来说,容量/速度都与价格成正比。为了能充分利用CPU,显然需要有与其速度相匹配的存储器;而随着计算机应用的深入,程序肯定是越来越复杂,进而占用空间越来越大。那么,能否找到一种解决方案,在合理的价格范围内,既有大的容量又有快的速度?答案是肯定的,这就是我们目前常用的层级结构(memory hierarchy)。

图1 一种典型的存储层级结构

正如计算机界的一句名言,计算机科学中的任何问题都可以通过增加一个间接层来解决(,但通常会导致另一个问题)。一般我们只知道前半句,后半句却被省略了,但省略的往往是真相。

在单核时代,层级结构工作得很好(Jeff Preshing在文章” Memory Reordering Caught in the Act”试验了将两个线程绑定到同一CPU的reordering情况)。然而,随着多核CPU的发展,以及各种用于榨取CPU资源的技术如乱序执行等的广泛应用,原有的层级结构便带来了新的问题。

perfbook的附录C主要讨论了SMP系统的CPU Cache对软件设计的一些影响。这里先简单介绍一下Cache,如图2所示。

图2 Cache

图2的结构也称为two-way set-associative cache. 两路(two-way)意思是对于一个主存(main memory)数据块,可以有两个cache line与之对应,当然,最后是从中选择一个。图2表格的单元格称为cache entry,而cache entry中包含除了cache line之外,还有其他信息,即tag中包含的主存地址(memory address,也称physical address),以及flag bits中包含的cache line是否在有效状态。Cache line的大小一般是2的指数字节,图2中的Cache line大小为256字节。这样对于一个主存地址来说,可以取紧邻其低8位的那4位(bit8-bit11,最低位为bit0)的值,以此来选择cache line/cache entry(两者其实等价,后文不再细分,统称cache line),例如,对于地址0x12345E00,其bit8-bit11为0xE,则对应到槽0xE中的任一cache line;同样,地址0x43210E00也对应到槽0xE;由此可见,两路结构中一个槽可同时容纳两个主存块。若Cache结构为一路(one-way,也称Direct Mapped),则一个槽只有一个cache line,从而只能容纳一个主存块,我们假设CPU需要频繁地读取0x12345E00和0x43210E00的数据,由于这两个地址映射到同一个槽,而该槽只有一个cache line,这样,双方会不停地把对方换出去,频繁刷新Cache而严重影响性能。当然,路数多了也会增加查找延时。至于多少路较为合理,这是另一个问题了。

SMP系统中,每个CPU都有自己的Cache,结构如图3所示。

图3 现代计算机系统的Cache结构

显然,同一主存块可以对应到一个或多个CPU的cache line中,例如多个线程共用的全局变量。当一份数据放于多个地方时,我们便需要考虑一致性问题。对于图3的CPU Cache,解决一致性问题的方法称为缓存一致性协议(Cache-Coherence Protocols )。

书中介绍的MESI Protocol是一种缓存一致性协议,在此仅做粗略小结,以方便后续阅读,详细内容请直接查阅perfbook

(1)MESI Protocol中cache line的四种状态

注:图2所示tag中的2-bit可用来保存cache line的状态。这里有点类似读写锁的应用,写锁独占,而读锁共享。任意两个状态之间都可以相互转换,详见perfbook

(2)MESI Protocol中CPU之间的六种消息

注:移除表示将该cache line置为invalid

(3)常用操作中的状态变化和消息传递

例1:假设变量a的初值为0且在CPU1的cache中,现在CPU0要对a进行写入值为1的操作。

其步骤如下表:

注:步骤4在cache line切换为modified后,依靠Writeback消息来完成后续操作。CPU0对a进行写入操作,按理说无需知道a的初值,为什么还要发送”Read”消息呢?因为cache line中不止是包含a的数据,例如cache line有256字节而a可能只有4字节,写a不能使同数据块的其它值受影响。

例2:假设变量a不在任何一个CPU的cache中,而CPU0要读取a的值,如何完成?

CPU0发送”Read”消息,而主存回复”Read Response”消息。

MESI Protocol确实很好地解决了一致性问题,是否就足够好用了呢?就拿上述例1来说,CPU0执行完步骤1后,步骤2由CPU1执行(cache line transfer),而此刻CPU0则在等待,而这个等待时间相比于寄存器之间的数据移动操作(register-to-register instruction),则高出几个数量级。那么,如何让宝贵的CPU少等待甚至不等待呢?

图4 CPU0的等待

在面对一个问题时,特别是那种初步看来难以解决的问题,一般会想,问题能不能往后拖拖,甚至就不让有这个问题发生呢?显然,要满足MESI Protocol,图4中的CPU等待不可避免。那么,可否推迟呢?要知道cache line一般比一个变量大很多,由于数据的局部性,往往接连操作的多个变量会在同一个cache line中,若将这些写操作先缓存起来,到了一定时刻,再进行写cache line操作,这样,cache line transfer(例1步骤2)次数会少很多。其实仔细想一下,若CPU0一直只需执行写操作,那么它完全可以独立于其它CPU,因为其它CPU对其数据贡献为0,那么CPU0就可先把写操作全部缓存到某处,而继续执行程序。当然,在SMP系统中,为了效率,程序执行时一般会让多个CPU同时执行,那么,一个CPU的写操作很有可能需要通知其它CPU(例如全局变量的写),此外,一个CPU一般不会一直只有写操作。不过,对写操作先做缓存再择机写到cache line,确实是解决图4中的写等待问题的一个方法。于是,我们初步设计了图5所示的cache 结构。

图5 带store buffer的Cache

我们利用图5所示结构来让CPU0执行如下代码,初始情况假定a和b都为0,且a在CPU1的cache line中,而b在CPU0的cache line中。

// Listing 5.1
a = 1;
b = a + 1;
assert(b == 2);

步骤如下表:

从上述执行结果来看,增加了store buffer似乎使得前两个语句重排了(the effect of reordering memory)。此处一个主要问题,是因为即便a所在的cache line在别的CPU被移除,但由于store buffer的存在,导致了两份a的存在,进而导致了不一致现象。

于是,我们将图5结构稍作升级,如图6所示

图6 带转发功能的store buffer的Cache(Caches With Store Forwarding)

增加了”store forwarding”,CPU在载入数据时,会同时查看Cache和store buffer。这样,即使store buffer中的数据还未写到cache line,同一CPU自身的后续load操作依旧可以使用store buffer中的数据。如此,对同一CPU而言,保证了顺序性(each CPU will always see its own operations as if they happened in program order)。使用图6结构,执行Listing 5.1,步骤5可以载入正确的a值,从而不会使assert fails

我们利用图6所示结构来执行如下代码,初始情况假定a和b都为0,且a在CPU1的cache line中,而b在CPU0的cache line中。CPU0执行foo(),CPU1执行bar()

// Listing 6.1
void foo()
 { 
    a = 1;
    b = 1;
}
void bar() 
{
while(b == 0) continue;
    assert(a == 1);
}

步骤如下表:

从上述执行结果来看,在两个CPU同时运行的情况下,CPU1看到CPU0发生了重排(reordering memory),但从CPU0自身视角来说,则没有重排发生。也就是说,图6的结构解决了单CPU程序执行的重排问题,但多CPU的则未解决。其实只要缓存引入,就有可能发生重排效果。原因在于两个CPU对于写入的理解(要求)不一致,CPU0认为写入到store buffer就叫写入,而CPU1则认为写入到cache line才叫写入。对于CPU0来说,确实是写入到store buffer就足够了,但是对于CPU1来说,则是不够的。

那么,如何解决上述分歧呢?我们需要告诉CPU,以让它们有一致的理解。 于是,本文的主角内存屏障(Memory barrier)终于登台了,介绍它之前我们把程序Listing 6.1稍作调整,

// Listing 6.2
void foo()
{ 
    a = 1;
    smp_mb();
    b = 1;
}
void bar() 
{
    while(b == 0) continue;
    assert(a == 1);
}


Listing 6.2中smp_mb()便是内存屏障,它后面的语句要执行写入cache line的操作前,必须先把store buffer的内容处理完成。那么,要满足这个要求,CPU可以有如下对策,在执行b=1之前,乖乖地停下来专门处理store buffer;另一种对策则是,将b=1也写入store buffer,但把b写入cache line之前,要先把前面的处理完成。基于后一种对策,我们看Listing 6.2的执行步骤,初始条件与Listing 6.1相同

步骤如下表:

从上述执行结果来看,Listing6.2达到了预期。单纯从步骤数量来看,Listing6.2的执行成本肯定更高,这是不可避免的,毕竟,错误的低成本没有意义。

至此,我们引入了MESI Protocol ,store buffer,内存屏障,充分地榨取了CPU资源,尽量不让它打盹,并且运转符合预期。那么,CPU利用率是否还可挖掘呢?试想一下,如果CPU0的store buffer已满,而接着又要进行写入操作,那么CPU0就不得不等待CPU1的” Invalidate Acknowledge”消息了,以便将store buffer内容写出。我们知道,返回” Invalidate Acknowledge”需要将对应的cache line移除,若CPU1此刻正在进行自己的store buffer写出,这需要对应的cache line参与,此刻cache处于busy状态,导致无法及时使CPU0想要的cache line失效,进而无法及时返回” Invalidate Acknowledge”。那么CPU0此刻就只能闲着了。

那么,如何减少CPU0的等待呢?于是,与之前解决问题思路类似,我们引入了又一类型的缓存”invalidate queue”,如图7所示,注意,” invalidate queue”在cache和memory这一侧。有了”invalidate queue”之后,CPU对于”invalidate”消息的处理可以先不移除cache line,而是将其放入”invalidate queue”,之后便立即返回” Invalidate Acknowledge”。

图7 带”Invalidate Queue”的cache结构(Caches With Invalidate Queues)

若CPU需要对外发送某cache line的”Invalidate”消息时,必须要先检查Invalidate Queue中有无该cache line的”Invalidate”消息,若有,则需要先行处理。(试想,若不处理Invalidate Queue中对应的”Invalidate”消息?此刻对外发送”Invalidate”消息,证明CPU想写该Cache line,那么,很有可能会在store buffer中存储该写入操作,那么后续CPU再进行处理时,store buffer有写该cache line的信息,而invalidate queue有让该cache line失效的消息,那么,以哪个为准;若以store buffer为准,那如果” invalidate queue”中的消息是后于store buffer产生的,证明别的CPU后续又对该cache line进行了写入,若此刻以一个更前的值写cache line的话,肯定是错误的;反之亦然)

我们利用图7所示结构来执行Listing6.2代码,假设a和b初值为0,a同时在CPU0和CPU1的cache line中,b只在CPU0的cache line中,由此,a对应的cache line状态为”shared”,b对应的为”exclusive”或者”modified” ,CPU0执行foo(),而CPU1执行bar():

步骤如下表:

从上述执行结果来看,引入了”invalidate queue”确实加速了CPU 0的执行,但CPU1却被自己坑了。CPU1再次看到了CPU0重排的效果,或者说自己被重排了。原因在于二者对于”invalidate cache line”的理解不一致,CPU0认为既然发出了” Invalidate Acknowledge ”,你的cache 中就不会有a对应的cache line了,而CPU1则认为将”invalidate”消息放入”invalidate queue”,算是已经让a的cache line失效了,而这些跟cache没半毛线关系。只能说CPU1坑了自己,成全了别人。

那么,如何解决上述分歧呢?我们还是需要借助内存屏障让二者达成共识。猛然发现内存屏障的在沟通方面确实有两把刷子。

我们将Listing6.2稍作调整,

// Listing 7.1
void foo()
{ 
    a = 1;
    smp_mb();
    b = 1;
}
void bar() 
{
    while(b == 0) continue;
    smp_mb();
    assert(a == 1);
}

我们还是使用图7的结构,此次执行Listing7.1,初始条件依旧,

步骤如下表:

从上述执行结果来看,内存屏障再次保证了程序达到预期结果。当然,使用内存屏障后步骤更多了,也就是执行成本增加了。不过,还是那句话,结果正确的前提下降本才有意义。

我们为了约束与store buffer和invalidate queue相关的操作,用到了smp_mb()。也就是说,CPU执行指令中如果遇到了smp_mb(),则需要处理store buffer和invalidate queue。从Listing7.1来看,foo()中的smp_mb()其实只需要处理store buffer即可,而bar()中的则只需要处理invalidate queue。有些CPU提供了更为细分的内存屏障,包括” read memory barrier”和” write memory barrier”,前者只会处理invalidate queue,而后者只会处理store buffer,其函数可分别记为smp_rmb()和smp_wmb()。显然,只处理其中之一肯定比同时处理二者效率要高,当然,约束就更少,可能的行为也就越多。对于Listing 7.1,我们可以将foo()和bar()中的smp_mb()分别替换为smp_wmb()和smp_rmb().

编辑于 2019-01-27