写屏障指令
cpu为了优化指令的执行效率,引入了store buffer(forwarding),而又因此导致了指令执行顺序的变化。
要保证这种顺序一致性,无法靠硬件优化,需要在软件层面支持,cpu提供了写屏障(write memory barrier)指令,
Linux操作系统将写屏障指令封装成了smp_wmb()函数,cpu执行smp_mb()的思路是,会先把当前store buffer中的数据刷到cache之后,
再执行屏障后的"写入操作",该思路有两种实现方式: 一是简单地刷store buffer,但如果此时远程cache line没有返回,则需要等待,
二是将当前store buffer中的条目打标,然后将屏障后的"写入操作"也写到store buffer中,cpu继续干其他的事,当被打标的条目全部刷到cache line,
之后再刷后面的条目,以第二种实现逻辑为例,我们看看以下代码执行过程:
void foo() { a = 1; smp_wmb() b = 1; } void bar() { while (b == 0) continue; assert(a == 1) }
cpu1执行while(b == 0),由于cpu1的cache中没有b,发出Read b消息。
cpu0执行a=1,由于cpu0的cache中没有a,因此它将a(当前值1)写入到store buffer并发出Read Invalidate a消息。
cpu0看到smp_wmb()内存屏障,它会标记当前store buffer中的所有条目(即a=1被标记)。
cpu0执行b=1,尽管b已经存在在cache中(Exclusive),但是由于store buffer中还存在被标记的条目,因此b不能直接写入,只能先写入store buffer中。
cpu0收到Read b消息,将cache中的b(当前值0)返回给cpu1,将b写回到内存,并将cache line状态改为Shared。
cpu1收到包含b的cache line,继续while (b == 0)循环。
cpu1收到Read Invalidate a消息,返回包含a的cache line,并将本地的cache line置为Invalid。
cpu0收到cpu1传过来的包含a的cache line,然后将store buffer中的a(当前值1)刷新到cache line,并且将cache line状态置为Modified。
由于cpu0的store buffer中被标记的条目已经全部刷新到cache,此时cpu0可以尝试将store buffer中的b=1刷新到cache,
但是由于包含b的cache line已经不是Exclusive而是Shared,因此需要先发Invalidate b消息。
cpu1收到Invalidate b消息,将包含b的cache line置为Invalid,返回Invalidate ACK。
cpu1继续执行while(b == 0),此时b已经不在cache中,因此发出Read消息。
cpu0收到Invalidate ACK,将store buffer中的b=1写入Cache。
cpu0收到Read消息,返回包含b新值的cache line。
cpu1收到包含b的cache line,可以继续执行while(b == 0),终止循环,然后执行assert(a == 1),此时a不在其cache中,因此发出Read消息。
cpu0收到Read消息,返回包含a新值的cache line。
cpu1收到包含a的cache line,断言为真。
Invalid Queue
引入了store buffer,再辅以store forwarding,写屏障,看起来好像可以自洽了,然而还有一个问题没有考虑: store buffer的大小是有限的,
所有的写入操作发生cache missing(数据不再本地)都会使用store buffer,特别是出现内存屏障时,
后续的所有写入操作(不管是否cache missing)都会挤压在store buffer中(直到store buffer中屏障前的条目处理完),因此store buffer很容易会满,
当store buffer满了之后,cpu还是会卡在等对应的Invalidate ACK以处理store buffer中的条目。因此还是要回到Invalidate ACK中来,
Invalidate ACK耗时的主要原因是cpu要先将对应的cache line置为Invalid后再返回Invalidate ACK,一个很忙的cpu可能会导致其它cpu都在等它回Invalidate ACK。
解决思路还是化同步为异步: cpu不必要处理了cache line之后才回Invalidate ACK,而是可以先将Invalid消息放到某个请求队列Invalid Queue,
然后就返回Invalidate ACK。CPU可以后续再处理Invalid Queue中的消息,大幅度降低Invalidate ACK响应时间。
加入了invalid queue之后,cpu在处理任何cache line的MSEI状态前,都必须先看invalid queue中是否有该cache line的Invalid消息没有处理。
另外,它也再一次破坏了内存的一致性。请看代码:
//假设a, b的初始值为0,a在cpu0,cpu1中均为Shared状态,b在cpu0独占(Exclusive状态),cpu0执行foo,cpu1执行bar void foo() { a = 1; smp_wmb() b = 1; } void bar() { while (b == 0) continue; assert(a == 1) }
cpu0执行a=1,由于其有包含a的cache line,将a写入store buffer,并发出Invalidate a消息。
cpu1执行while(b == 0),它没有b的cache,发出Read b消息。
cpu1收到cpu0的Invalidate a消息,将其放入Invalidate Queue,返回Invalidate ACK。
cpu0收到Invalidate ACK,将store buffer中的a=1刷新到cache line,标记为Modified。
cpu0看到smp_wmb()内存屏障,但是由于其store buffer为空,因此它可以直接跳过该语句。
cpu0执行b=1,由于其cache独占b,因此直接执行写入,cache line标记为Modified。 cpu0收到cpu1发的Read b消息,将包含b的cache line写回内存并返回该cache line,本地的cache line标记为Shared。
cpu1收到包含b(当前值1)的cache line,结束while循环。
cpu1执行assert(a == 1),由于其本地有包含a旧值的cache line,读到a初始值0,断言失败。
cpu1这时才处理Invalid Queue中的消息,将包含a旧值的cache line置为Invalid。
问题在于第9步中cpu1在读取a的cache line时,没有先处理Invalid Queue中该cache line的Invalid操作,其实cpu还提供了读屏障指令,Linux将其封装成smp_rmb()函数,将该函数插入到bar函数中,就像这样:
void foo() { a = 1; smp_wmb() b = 1; } void bar() { while (b == 0) continue; smp_rmb() assert(a == 1) }
和smp_wmb()类似,cpu执行smp_rmb()的时,会先把当前invalidate queue中的数据处理掉之后,再执行屏障后的"读取操作"。
备注
smp_rmb(): 在invalid queue的数据被刷完之后再执行屏障后的读操作。
smp_wmb(): 在store buffer的数据被刷完之后再执行屏障后的写操作。
smp_mb(): 同时具有读屏障和写屏障功能。