MESI--CPU缓存一致性协议

MESI协议能干什么

CPU在进行读写cache的时候,不能简单地读写,因为如果只修改本地CPU的cache,而不处理其他CPU上的同一个数据,那么就会造成一份数据多个不同副本,这就是数据冲突。所以我们在多CPU的情况怎么写一个数据呢,我们应该在写之前通知其他的CPU该数据失效,当获取到所有其他CPU的回复之后,我们才能写本地的cache,在这个写操作之后,如果其他CPU试图读取该数据时,会发现cache miss,这种miss叫做 communication miss,因为这经常是多个cpu使用一个变量进行数据同步的时候产生的。我们发现需要做很多工作来防止数据冲突,而我们用来保证数据不冲突的方法就是“缓存一致性协议”。
MESI协议只是缓存一致性协议中的一种,但是是用的最广泛的一种、
注意:MESI协议是需要进行触发的,默认情况下CPU是不会启用MESI协议的,只有在遇到带有LOCK#前缀的指令的时候,才会去触发MESI一致性协议

MESI协议中的状态

CPU中每个缓存行(caceh line,缓存中可分配的最小存储单元)使用4种状态进行标记(使用额外的两位(bit)表示):
M: 被修改(Modified)
该缓存行只被缓存在该CPU的缓存中,并且是被修改过的(dirty),即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它CPU读取请主存中相应内存之前)写回(write back)主存。
当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。
E: 独享的(Exclusive)
该缓存行只被缓存在该CPU的缓存中,它是未被修改过的(clean),与主存中数据一致。该状态可以在任何时刻当有其它CPU读取该内存时变成共享状态(shared)。
同样地,当CPU修改该缓存行中内容时,该状态可以变成Modified状态。
S: 共享的(Shared)
该状态意味着该缓存行可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致(clean),当有一个CPU修改该缓存行中,其它CPU中该缓存行可以被作废(变成无效状态(Invalid))。
I: 无效的(Invalid)
该缓存是无效的(可能有其它CPU修改了该缓存行)。

MESI状态转换图

  1. local read:处理器的一次读操作,可能是从本地缓存中读,可能是从内存中读,主要是看本地缓存中有没有
  2. local write:处理器的一次写操作,对cache的写
  3. remote read:其他处理器的对缓存的读
  4. remote write:其他处理器的对缓存的写操作
    CPU通过总线嗅探机制对总线进行嗅探,CPU能从总线上嗅探到的4种事件,对应的事件CPU会进行不同的处理。
    在这里插入图片描述
    这里首先介绍该协议约定的缓存上对应的监听:
  • 一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回CPU,将自己的状态变成s。
  • 一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。
  • 一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到读操作,则必须把其缓存行状态设置为S。

上面看到了状态的转换,下面是CPU之间进行通信的几种消息

  1. Read:“Read”消息包含要读取的缓存行的物理地址
  2. Read Response:“Read Response”消息包含先前的“Read”消息请求的数据。这个“read response”消息可以由内存提供,也可以由其他缓存提供。例如,如果其中一个处于M状态或者E状态的缓存有的所需数据,则该缓存必须提供“read response”消息。
  3. Invalidate: Invalidate消息包含了将要失效的缓存行的物理地址。所有其他缓存必须从其缓存中删除相应的数据并进行响应。
  4. Invalidate Acknowledge:一个CPU接收到一个“invalidate”消息必须在从其缓存中删除指定的数据后用“invalidate确认”消息进行响应。
  5. Read Invalidate:“Read Invalidate”消息包含要读取的缓存行的物理地址,同时指示其他缓存删除数据。因此,它是一个“Read”和“Invalidate”的组合,正如其名称所示。一个“read invalidate”消息需要一个“read response”和一组“invalidate Acknowledge”消息的回复。
  6. Writeback:“Writeback”消息包含要写回内存的地址和数据(可能还会“窥探”其他cpu的缓存)。此消息允许缓存根据需要弹出处于“修改”状态的行,以便为其他数据腾出空间

MESI协议的优化

虽然MESI协议能保证读写内存的高性能,但还是有点问题:
在这里插入图片描述
当cpu0要写数据到本地cache的时候,如果不是M或者E状态,需要发送一个invalidate消息给cpu1,只有收到cpu1的acknowledgement才能写数据到cache中,在这个过程中cpu0需要等待,这大大影响了性能。一种解决办法是在cpu和cache之间引入store buffer,当发出invalidate之后直接把数据写入store buffer。当收到acknowledgement之后可以把store buffer中的数据写入cache。现在的架构图是这样的:
在这里插入图片描述
现在这样的架构引入了复杂性,看下面的例子:
cpu0cache里面有个b,初值为0,cpu1cache有个a,初值为0,现在cpu0运行代码

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

执行情况

  1. cpu0执行a=1的时候发现本地cache没有a,所以发送read invalidate给cpu1,然后把a=1写入store buffer
  2. cpu1收到read invalidate之后,因为cpu1的a现在还是有效的,所以把a传给cpu0并且本地cacheline置为无效
  3. cpu0开始执行b=a+1
  4. cpu0收到cpu1的read response,其中包含了a的值,a的值仍然是0,存入缓存
  5. cpu0执行a+1,从缓存中加载a,得到1赋给b
  6. cpu0执行最后一句,失败

这里关键的问题是cpu会把自己的操作看做是全局的内存操作,但其实操作store buffer没有操作到主存,所以我们需要在查cache的时候还得查一下store buffer,这种技术叫做store forwarding.
现在的架构是这样的:
在这里插入图片描述
上面是store buffer在一个cpu中碰到的问题,在多个cpu并发的过程中也可能存在问题,看下例:

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

同样的,cpu0 cache里面有个b,初值为0,cpu1 cache有个a,初值为0。

  1. cpu0运行foo, cpu1运行bar
  2. cpu0 发现a不在本地cache,发送read invalidate消息,并在store buffer中把a置为1
  3. cpu1 执行while (b == 0)发现b不在本地内存,发送read消息
  4. cpu0 在本地cache置b为1
  5. cpu0收到read消息,把cache中的b传送给cpu1,并把本地状态置为s
  6. cpu1发现b为1,退出循环,因为这时候cpu1本地cache中a还是1,所以断言失败
  7. cpu1收到read invalidate,把a传输给cpu0,并置本地cache为invalidate,但是太晚了
  8. cpu0收到cpu1关于a的read response,把本地的store buffer移到cache中

第一个问题硬件工程署可以解决,但是第二个很难处理,因为硬件无法知道变量之间的依赖关系,硬件工程师设计了memory barrier(内存屏障),软件可以使用这个工具来提示cpu变量之间的关系。新的代码如下:

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

内存屏障smp_mb()提示cpu在进行smp_mb之后的写操作的时候,会先把store buffer里的数据刷新到cache中。
有两种方式

  1. cpu会等到store buffer清空之后再处理其他指令,清空的条件是必须要等到其他CPU的确认响应
  2. 之后的所有写操作都不写到cache,而是写到store buffer中,直到smp_mb之前的store buffer中的数据刷新到cache中。

上例中的执行效果如下:

  1. cpu0执行 a=1,发现a不在本地cache中,进而把a=1写入store buffer,并发出read invalidate消息
  2. cpu1执行while (b == 0),发现b不在本地cache中,进而发出read消息
  3. cpu0执行smp_mb,把store buffer中的a标记一下
  4. cpu0执行b=1,发现状态为独占,所以可以直接写,但是因为store buffer中有标记过的值,所以把b=1写入store buffer,但是不标记
  5. cpu0收到read消息,把cache中b的数据0发给cpu1,并把cacheline置为s cpu1收到b=0,陷入循环中
  6. cpu0收到read invalidate消息,进而把a=1从store buffer写入cache,这时候可以把store buffer中的b=1写入cache,但是发现这时候cache中的b属于s状态,所以发出invalidate消息。
  7. cpu1收到invalidate消息之后把b设为1
  8. cpu0收到invalidate ack之后把b的值1写入cache
  9. cpu1要读取b的值,发出read消息
  10. cpu0把b=1发给cpu1
  11. cpu1收到b的值1,退出循环
  12. cpu1发现a无效,发出read消息给cpu0
  13. cpu0把a的值1发送给cpu1,并且把a置为s
  14. cpu1得到a=1,成功

但是内存屏障的处理方法有个问题,那就是store buffer空间是有限的,如果store buffer中的空间被smp_mb之后的存储塞满,cpu还是得等待invalidate消息返回才能继续处理。解决这种问题的思路是让invalidate ack能更早得返回,一种办法是提供一种放置invalidate message的队列,称为invalidate queue,cpu可以在收到invalidate之后马上返回invalidate ack,而不是在把本地cache invalidate之后,并把invalidate message放置到invalide queue,以待之后处理。
在这里插入图片描述
但是这种方法会使得我们之前的内存屏障的例子也失效,主要是因为在cpu1收到cpu0关于a的invalidate消息之后直接ack,而没有真正invalidate cache,导致退出循环之后发现a是有效的,其值还是0,执行assert(a==1)失败
我们需要修改之前的例子让断言通过

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

在assert之前插入内存屏障,作用是把invalidate queue标记下,在读取下面的数据的时候,譬如a的时候会先把invalidate queue中的消息都处理掉,这里的话会使得a失效而去cpu0获取最新的数据。

进而我们知道smp_mb有两个作用

  1. 标记store buffer,在处理之后的写请求之前需要把store buffer中的数据apply到cache
  2. 标记invalidate queue,在加载之后的数据之前把invalidate queue中的消息都处理掉

进而我们再观察上面的例子,我们发现,在foo中我们不需要处理invalidate queue,而在bar中,我们不需要处理store buffer,我们可以使用一种更弱的内存屏障来修改上例让我们程序的性能更高,smp_wmb写屏障,只会标记store buffer,smp_rmb读屏障,只会标记invalidate queue,代码如下:

void foo(void)
{
	a = 1;
	smp_wmb();
	b = 1;
}
void bar(void)
{
	while (b == 0) continue;
	smp_rmb();
	assert(a == 1);
}

翻译自:http://www.puppetmastertrading.com/images/hwViewForSwHackers.pdf
intel:https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-software-developer-vol-3a-part-1-manual.pdf

MESI与volatile的关系

在我的理解中,MESI协议是实现volatile的所有语义的基础,在我们对一个变量加上volitile之后,该变量的操作的指令前就会带有LOCK#前缀,该前缀在intel的文档里面说的很清楚,可以通过上面的链接查看,这里只列举出部分
在这里插入图片描述
Lock前缀具有如下作用:

  1. 带有lock前缀的指令在执行的时候会锁住总线或者利用MESI协议这两种方式来保证指令执行的原子性,
  2. 禁止该指令,与之前和之后的读和写指令重排序
  3. 把缓冲区的所有数据刷新到内存中

我们通过生成的汇编代码来进行查看:

Argument 0 is unknown.RIP: 0x7f96b93132a0 Code size: 0x00000150
[Entry Point]
[Verified Entry Point]
[Constants]
  # {method} {0x00007f96b7106238} 'main' '([Ljava/lang/String;)V' in 'TestVolatile'
  # parm0:    rsi:rsi   = '[Ljava/lang/String;'
  #           [sp+0x40]  (sp of caller)
  #main方法入口
  0x00007f96b93132a0: mov     %eax,0xfffffffffffec000(%rsp)
  0x00007f96b93132a7: push    %rbp
  0x00007f96b93132a8: sub     $0x30,%rsp
  0x00007f96b93132ac: movabs  $0x7f96b7106300,%rdi  ;   {metadata(method data for {method} {0x00007f96b7106238} 'main' '([Ljava/lang/String;)V' in 'TestVolatile')}
  0x00007f96b93132b6: mov     0xdc(%rdi),%ebx
  0x00007f96b93132bc: add     $0x8,%ebx
  0x00007f96b93132bf: mov     %ebx,0xdc(%rdi)
  0x00007f96b93132c5: movabs  $0x7f96b7106238,%rdi  ;   {metadata({method} {0x00007f96b7106238} 'main' '([Ljava/lang/String;)V' in 'TestVolatile')}
  0x00007f96b93132cf: and     $0x0,%ebx
  0x00007f96b93132d2: cmp     $0x0,%ebx
  0x00007f96b93132d5: je      0x7f96b931330c    ;*bipush
                                                ; - TestVolatile::main@0 (line 5)
 
  0x00007f96b93132db: movabs  $0xf066da08,%rsi  ;   {oop(a 'java/lang/Class' = 'TestVolatile')}
  #将9赋值给edi寄存器
  0x00007f96b93132e5: mov     $0x9,%edi
  #将edi寄存器的值赋值给value
  0x00007f96b93132ea: mov     %edi,0x68(%rsi)
  #带lock前缀的加指令,把rsp所指向的地址中值加0,这个指令没啥用,主要使用lock前缀做内存屏障的
  #防止lock之后的指令在lock之前执行,这里没使用mfence指令,主要是mfence在某些情况下比lock效率慢
  0x00007f96b93132ed: lock addl $0x0,(%rsp)     ;*putstatic value
                                                ; - TestVolatile::main@5 (line 6)
  #将value的值赋值给edi寄存器
  0x00007f96b93132f2: mov     0x68(%rsi),%edi   ;*getstatic value
                                                ; - TestVolatile::main@8 (line 7)
  #将edi寄存器加10
  0x00007f96b93132f5: add     $0xa,%edi
  #将edi寄存器赋值给value
  0x00007f96b93132f8: mov     %edi,0x68(%rsi)
  #加lock前缀做内存屏障,防止lock后的指令跑到lock前执行
  0x00007f96b93132fb: lock addl $0x0,(%rsp)     ;*putstatic value
                                                ; - TestVolatile::main@13 (line 7)
  #从main方法返回
  0x00007f96b9313300: add     $0x30,%rsp
  0x00007f96b9313304: pop     %rbp
  0x00007f96b9313305: test    %eax,0x165dcdf5(%rip)  ;   {poll_return}
  0x00007f96b931330b: retq

看出来了什么没有,即使是加了volatile之后的变量,对应到的读取和写入指令都没有加上Lock#前缀,从汇编语言中可以看到在对volatile变量赋值后会加一条lock addl $0x0,(%rsp)指令,lock指令具有内存屏障的作用,lock前后的指令不会重排序,addl $0x0,(%rsp)是一条无意义的指令。所以说我们对volatile变量的操作其实还是不具有原子性,因为只是利用了#Lock前缀保证了写操作会被马上刷新到内存而已,并没有保证读写改三个操作的原子性。
为什么是在写操作后面插入一条带有Lock#的指令?
这一条指令其实是起到内存屏障的所用,LOCK前缀虽然不是内存屏障指令,但是他能起到内存屏障的效果。因为我的测试环境是X86平台,在X86平台上,只会存在StoreLoad重排序,所以说java编译器在编译volatile变量的操作的时候,只需要在所有的volatile写的后面插入一个StoreLoad屏障,以此来实现可见性。lock前缀让本核操作内存时锁定其他核,addl xxx是个无意义的内存操作,可令CPU清空WB,也起到了内存屏障的作用了。
CAS就能保证原子性,CAS也是加LOCK#前缀啊,这又是为什么?因为CAS操作是在一条单独的指令cmpxchg前加上了#Lock前缀 ,所以它具有原子性,LOCK#能保证一条指令执行的原子性。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值