内存屏障(Memory Barrier)到底是个什么鬼?

读者朋友你好hello缓存

在开始阅读以前咱们假设读者已经掌握了缓存一致性协议的MESI相关知识。若是没有建议阅读 带你了解缓存一致性协议 MESI安全


问题的产生微信

如上图 CPU 0 执行了一次写操做,可是此时 CPU 0 的 local cache 中没有这个数据。因而 CPU 0 发送了一个 Invalidate 消息,其余全部的 CPU 在收到这个 Invalidate 消息以后,须要将本身 CPU local cache 中的该数据从 cache 中清除,而且发送消息 acknowledge 告知 CPU 0。CPU 0 在收到全部 CPU 发送的 ack 消息后会将数据写入到本身的 local cache 中。这里就产生了性能问题:当 CPU 0 在等待其余 CPU 的 ack 消息时是处于停滞的(stall)状态,大部分的时间都是在等待消息。为了提升性能就引入的 Store Buffer。函数


Store Buffer工具

store buffer 的目的是让 CPU 再也不操做以前进行漫长的等待时间,而是将数据先写入到 store buffer 中,CPU 无需等待能够继续执行其余指令,等到 CPU 收到了 ack 消息后,再从 store buffer 中将数据写入到 local cache 中。有了 store buffer 以后性能提升了许多,但常言道:“有一利必有一弊。”store buffer 虽然提升了性能可是却引入了新的问题。oop

a = 0 , b = 0;
a = 1;
b = a + 1;
assert(b == 2);

假设变量 a 在 CPU 1 的 cache line 中 , 变量 b 在 CPU 0 的 cache line 中。上述代码的执行序列以下:性能

1. CPU 0 执行 a = 1。spa

2. CPU 0 local cache 中没有 a ,发生 cache miss 。设计

3. CPU 0 发送 read invalidate 消息获取 a ,同时让其余 CPU local cache 中的 a 被清除。code

4. CPU 0 把须要写入 a 的值 1 放入了 store buffer 。

5. CPU 1 收到了 read invalidate 消息,回应了 read response 和 acknowledge 消息,把本身 local cache 中的 a 清除了。

6. CPU 0 执行 b = a + 1 。

7. CPU 0 收到了 read response 消息获得了 a 的值是 0 。

8. CPU 0 从 cache line 中读取了 a 值为 0 。

9. CPU 0 执行 a + 1 , 并写入 b ,b 被 CPU 0 独占因此直接写入 cache line , 这时候 b 的值为 1。

10. CPU 0 将 store buffer 中 a 的值写入到 cache line , a 变为 1。

11. CPU 0 执行 assert(b == 2) , 程序报错。

致使这个问题是由于 CPU 对内存进行操做的时候,顺序和程序代码指令顺序不一致。在写操做执行以前就先执行了读操做。另外一个缘由是在同一个 CPU 中同一个数据存在不一致的状况 , 在 store buffer 中是最新的数据, 在 cache line 中是旧的数据。为了解决在同一个 CPU 的 store buffer 和 cache 之间数据不一致的问题,引入了 Store Forwarding。store forwarding 就是当 CPU 执行读操做时,会从 store buffer 和 cache 中读取数据, 若是 store buffer 中有数据会使用 store buffer 中的数据,这样就解决了同一个 CPU 中数据不一致的问题。可是因为 Memory Ordering 引发的问题尚未解决。


内存操做顺序

Memory Ordering

a = 0 , b = 0;
void fun1() {   
  a = 1;   
  b = 1;
}

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

​​​​​​​

假设 CPU 0 执行 fun1() , CPU 1 执行 fun2() , a 变量在 CPU 1 cache 中 , b 变量在 CPU 0 cache 中。 上述代码的执行序列以下:

  1.  CPU 0执行a=1的赋值操做,因为a不在local cache中,所以,CPU 0将a值放到store buffer中以后,发送了read invalidate命令到总线上去。
  2. CPU 1执行 while (b == 0) 循环,因为b不在CPU 1的cache中,所以,CPU发送一个read message到总线上,看看是否能够从其余cpu的local cache中或者memory中获取数据。
  3.  CPU 0继续执行b=1的赋值语句,因为b就在本身的local cache中(cacheline处于modified状态或者exclusive状态),所以CPU0能够直接操做将新的值1写入cache line。
  4. CPU 0收到了read message,将最新的b值”1“回送给CPU 1,同时将b cacheline的状态设定为shared。
  5. CPU 1收到了来自CPU 0的read response消息,将b变量的最新值”1“值写入本身的cacheline,状态修改成shared。
  6. 因为b值等于1了,所以CPU 1跳出while (b == 0)的循环,继续执行。
  7.  CPU 1执行assert(a == 1),这时候CPU 1的local cache中仍是旧的a值,所以assert(a == 1)失败。
  8. CPU 1收到了来自CPU 0的read invalidate消息,以a变量的值进行回应,同时清空本身的cacheline。
  9. CPU 0收到了read response和invalidate ack的消息以后,将store buffer中的a的最新值”1“数据写入cacheline。

产生问题的缘由是 CPU 0 对 a 的写操做尚未执行完,可是 CPU 1 对 a 的读操做已经执行了。毕竟CPU并不知道哪些变量有相关性,这些变量是如何相关的。不过CPU设计者能够间接提供一些工具让软件工程师来控制这些相关性。这些工具就是 memory barrier 指令。要想程序正常运行,必须增长一些 memory barrier 的操做。


写内存屏障

Store Memory Barrier

a = 0 , b = 0;
void fun1() {   
  a = 1;   
  smp_mb();   
  b = 1;
}

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

smp_mb() 这个内存屏障的操做会在执行后续的store操做以前,首先flush store buffer(也就是将以前的值写入到cacheline中)。smp_mb() 操做主要是为了让数据在local cache中的操做顺序是符合program order的顺序的,为了达到这个目标有两种方法:方法一就是让CPU stall,直到完成了清空了store buffer(也就是把store buffer中的数据写入cacheline了)。方法二是让CPU能够继续运行,不过须要在store buffer中作些文章,也就是要记录store buffer中数据的顺序,在将store buffer的数据更新到cacheline的操做中,严格按照顺序执行,即使是后来的store buffer数据对应的cacheline已经ready,也不能执行操做,要等前面的store buffer值写到cacheline以后才操做。增长smp_mb() 以后,操做顺序以下:

1. CPU 0执行a=1的赋值操做,因为a不在local cache中,所以,CPU 0将a值放 store buffer中以后,发送了read invalidate命令到总线上去。

2. CPU 1执行 while (b == 0) 循环,因为b不在CPU 1的cache中,所以,CPU发送一个read message到总线上,看看是否能够从其余cpu的local cache中或者memory中获取数据。

3. CPU 0执行smp_mb()函数,给目前store buffer中的全部项作一个标记(后面咱们称之marked entries)。固然,针对咱们这个例子,store buffer中只有一个marked entry就是“a=1”。

4. CPU 0继续执行b=1的赋值语句,虽然b就在本身的local cache中(cacheline处于modified状态或者exclusive状态),不过在store buffer中有marked entry,所以CPU0并无直接操做将新的值1写入cache line,取而代之是b的新值”1“被写入store buffer,固然是unmarked状态。

5. CPU 0收到了read message,将b值”0“(新值”1“还在store buffer中)回送给CPU 1,同时将b cacheline的状态设定为shared。

6.  CPU 1收到了来自CPU 0的read response消息,将b变量的值(”0“)写入本身的cacheline,状态修改成shared。

7. 完成了bus transaction以后,CPU 1能够load b到寄存器中了(local cacheline中已经有b值了),固然,这时候b仍然等于0,所以循环不断的loop。虽然b值在CPU 0上已经赋值等于1,可是那个新值被安全的隐藏在CPU 0的store buffer中。

8. CPU 1收到了来自CPU 0的read invalidate消息,以a变量的值进行回应,同时清空本身的cacheline。

9. CPU 0将store buffer中的a值写入cacheline,而且将cacheline状态修改成modified状态。

10. 因为store buffer只有一项marked entry(对应a=1),所以,完成step 9以后,store buffer的b也能够进入cacheline了。不过须要注意的是,当前b对应的cacheline的状态是shared。

11.  CPU 0发送invalidate消息,请求b数据的独占权。

12.  CPU 1收到invalidate消息,清空本身的b cacheline,并回送acknowledgement给CPU 0。

13. CPU 1继续执行while (b == 0),因为b不在本身的local cache中,所以 CPU 1发送read消息,请求获取b的数据。

14. CPU 0收到acknowledgement消息,将b对应的cacheline修改为exclusive状态,这时候,CPU 0终于能够将b的新值1写入cacheline。

15. CPU 0收到read消息,将b的新值1回送给CPU 1,同时将其local cache中b对应的cacheline状态修改成shared。

16.  CPU 1获取来自CPU 0的b的新值,将其放入cacheline中。

17. 因为b值等于1了,所以CPU 1跳出while (b == 0)的循环,继续执行。

18. CPU 1执行assert(a == 1),不过这时候a值没有在本身的cacheline中,所以须要经过cache一致性协议从CPU 0那里得到,这时候获取的是a的最新值,也就是1值,所以assert成功。

经过上面的描述,咱们能够看到,一个直观上很简单的给a变量赋值的操做,都须要那么长的执行过程,并且每一步都须要芯片参与,最终完成整个复杂的赋值操做过程。

上述这个例子展现了 write memory barrier , 简单来讲在屏障以后的写操做必须等待屏障以前的写操做完成才能够执行,读操做则不受该屏障的影响。


顺序写操做致使了 CPU 的停顿

Store Sequences Result in Unnecessary Stalls

按照矛盾的角度来看解决了一个问题以后伴随着又产生了一个新的问题:每一个cpu的store buffer不能实现的太大,其entry的数目不会太多。当cpu以中等的频率执行store操做的时候(假设全部的store操做致使了cache miss),store buffer会很快的被填满。在这种情况下,CPU只能又进入等待状态,直到cache line完成invalidate和ack的交互以后,能够将store buffer的entry写入cacheline,从而为新的store让出空间以后,CPU才能够继续执行。这种情况也可能发生在调用了memory barrier指令以后,由于一旦store buffer中的某个entry被标记了,那么随后的store都必须等待invalidate完成,所以不论是否cache miss,这些store都必须进入store buffer。为了解决这个问题引入了 invalidate queues 能够缓解这个情况。store buffer之因此很容易被填充满,主要是其余CPU回应invalidate acknowledge比较慢,若是可以加快这个过程,让store buffer尽快进入cacheline,那么也就不会那么容易填满了。

invalidate acknowledge不能尽快回复的主要缘由是invalidate cacheline的操做没有那么快完成,特别是cache比较繁忙的时候,这时,CPU每每进行密集的loading和storing的操做,而来自其余CPU的,对本CPU local cacheline的操做须要和本CPU的密集的cache操做进行竞争,只要完成了invalidate操做以后,本CPU才会发生invalidate acknowledge。此外,若是短期内收到大量的invalidate消息,CPU有可能跟不上处理,从而致使其余CPU不断的等待。

然而,CPU其实不须要完成invalidate操做就能够回送acknowledge消息,这样,就不会阻止发生invalidate请求的那个CPU进入无聊的等待状态。CPU能够buffer这些invalidate message(放入Invalidate Queues),而后直接回应acknowledge,表示本身已经收到请求,随后会慢慢处理。固然,再慢也要有一个度,例如对a变量cacheline的invalidate处理必须在该CPU发送任何关于a变量对应cacheline的操做到bus以前完成。

有了Invalidate Queue的CPU,在收到invalidate消息的时候首先把它放入Invalidate Queue,同时马上回送acknowledge 消息,无需等到该cacheline被真正invalidate以后再回应。固然,若是本CPU想要针对某个cacheline向总线发送invalidate消息的时候,那么CPU必须首先去Invalidate Queue中看看是否有相关的cacheline,若是有,那么不能马上发送,须要等到Invalidate Queue中的cacheline被处理完以后再发送。一旦将一个invalidate(例如针对变量a的cacheline)消息放入CPU的Invalidate Queue,实际上该CPU就等于做出这样的承诺:在处理完该invalidate消息以前,不会发送任何相关(即针对变量a的cacheline)的MESI协议消息。


读内存屏障

Load Memory Barrier

a = 0 , b = 0;
void fun1() {    
  a = 1;    
  smp_mb();    
  b = 1;
}

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

假设 a 存在于 CPU 0 和 CPU 1 的 local cache 中,b 存在于 CPU 0 中。CPU 0 执行 fun1() , CPU 1 执行 fun2() 。操做序列以下:

  1.  CPU 0执行a=1的赋值操做,因为a在CPU 0 local cache中的cacheline处于shared状态,所以,CPU 0将a的新值“1”放入store buffer,而且发送了invalidate消息去清空CPU 1对应的cacheline。

   2. CPU 1执行while (b == 0)的循环操做,可是b没有在local cache,所以发送read消息试图获取该值。

    3. CPU 1收到了CPU 0的invalidate消息,放入Invalidate Queue,并马上回送Ack。

    4. CPU 0收到了CPU 1的invalidate ACK以后,便可以越过程序设定内存屏障(第四行代码的smp_mb() ),这样a的新值从store buffer进入cacheline,状态变成Modified。

    5. CPU 0 越过memory barrier后继续执行b=1的赋值操做,因为b值在CPU 0的local cache中,所以store操做完成并进入cache line。

    6. CPU 0收到了read消息后将b的最新值“1”回送给CPU 1,并修正该cacheline为shared状态。

    7.  CPU 1收到read response,将b的最新值“1”加载到local cacheline。

    8. 对于CPU 1而言,b已经等于1了,所以跳出while (b == 0)的循环,继续执行后续代码。

     9.  CPU 1执行assert(a == 1),可是因为这时候CPU 1 cache的a值仍然是旧值0,所以assert 失败。

     10. Invalidate Queue中针对a cacheline的invalidate消息最终会被CPU 1执行,将a设定为无效。

很明显,在上面场景中,加速 ack 致使fun1()中的memory barrier失效了,所以,这时候对 ack 已经没有意义了,毕竟程序逻辑都错了。怎么办?其实咱们可让memory barrier指令和Invalidate Queue进行交互来保证肯定的memory order。具体作法是这样的:当CPU执行memory barrier指令的时候,对当前Invalidate Queue中的全部的entry进行标注,这些被标注的项次被称为marked entries,而随后CPU执行的任何的load操做都须要等到Invalidate Queue中全部marked entries完成对cacheline的操做以后才能进行。所以,要想保证程序逻辑正确,咱们须要给 fun2() 增长内存屏障的操做,具体以下:​​​​​​​

a = 0 , b = 0;
void fun1() {    
  a = 1;    
  smp_mb();    
  b = 1;
}

void fun2() {    
  while (b == 0) continue;    
  smp_rmb();
  assert(a == 1);
 }

当 CPU 1 执行完 while(b == 0) continue;  以后, 它必须等待 Invalidate Queues 中的 Invalidate 变量 a 的消息被处理完,将 a 从 CPU 1 local cache 中清除掉。而后才能执行 assert(a == 1)。CPU 1 在读取 a 时发生 cache miss ,而后发送一个 read 消息读取 a ,CPU 0 会回应一个 read response 将 a 的值发送给 CPU 1。


许多CPU architecture提供了弱一点的memory barrier指令只mark其中之一。若是只mark invalidate queue,那么这种memory barrier被称为read memory barrier。相应的,write memory barrier只mark store buffer。一个全功能的memory barrier会同时mark store buffer和invalidate queue。

咱们一块儿来看看读写内存屏障的执行效果:对于read memory barrier指令,它只是约束执行CPU上的load操做的顺序,具体的效果就是CPU必定是完成read memory barrier以前的load操做以后,才开始执行read memory barrier以后的load操做。read memory barrier指令象一道栅栏,严格区分了以前和以后的load操做。一样的,write memory barrier指令,它只是约束执行CPU上的store操做的顺序,具体的效果就是CPU必定是完成write memory barrier以前的store操做以后,才开始执行write memory barrier以后的store操做。全功能的memory barrier会同时约束load和store操做,固然只是对执行memory barrier的CPU有效。

看完本文有收获?请分享给更多人

微信关注「黑帽子技术」加星标,看精选 IT 技术文章

相关文章
相关标签/搜索