图解MESI(缓存一致性协议)

文章目录

  • Java内存模型
  • CPU缓存一致性
    • CPU Cache和内存数据不一致
      • 写直达
      • 写回
    • 多个CPU核心的数据不一致
      • 总线嗅探
      • MESI(缓存一致性协议)
  • 总结

Java内存模型

做Java开发的老哥们都知道不管在面试还是学习一些底层知识的时候总是会看到一个叫“JMM”的东西,这个JMM其实就是Java内存模型。

JMM决定一个线程对共享变量的写入何时对另一个线程可见(即解决“内存可见性问题”)。

JMM定义了主内存和线程之间的抽象关系:
图解MESI(缓存一致性协议)_第1张图片

  1. 该内存模型针对的是共享变量(堆内存变量)。对于局部变量这种存在栈内存上的非共享变量,它们不会有内存可见性问题,也不受内存模型的影响。
  2. 线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。
  3. 本地内存是JMM的一个抽象概念,并不真实存在,你可以理解为“CPU高速缓冲”。

如果线程A与线程B之间要通信的话,必须要经历下面2个步骤:
1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2)线程B到主内存中去读取线程A之前已更新过的共享变量。

JMM只是Java语言层面的抽象,底层还是依赖CPU等硬件层面的支持。

CPU缓存一致性

图解MESI(缓存一致性协议)_第2张图片

看到这张图是不是和上面的JMM图很像呢。

  1. 为了平衡CPU和内存的访问性能差异,引入了CPU Cache(高速缓存)。
  2. 当CPU读取数据时,尽可能从CPU Cache中读取,而不是每次都从内存中读取数据。

事实上数据不只有读操作,还会有写操作。这也引入了新的问题:
1)数据写入CPU Cache之后,CPU Cache和内存数据就会不一致。
2)数据写入某个CPU核心 Cache之后,就和其他CPU核心中缓存的数据不一致。

这和JMM要解决的问题是一样的:保证数据一致性的前提下的内存可见性

CPU Cache和内存数据不一致

其实就是要把Cache里的数据同步到内存中,有两种方法:写直达、写回。

写直达

保持内存与Cache一致性最简单的方式是,把数据同时写入内存和Cache中,这种方式称为写直达
图解MESI(缓存一致性协议)_第3张图片

  1. 如果数据已经在Cache里,先更新Cache,再写入内存。
  2. 如果数据没在Cache里,就只写把数据更新到内存里面。

优点:直观简单。
缺点:无论数据在不在Cache里,每次写操作都会写回到内存,性能会受到很大影响。

写回

在写回机制中,当发生写操作是,新的数据仅仅被写入Cache,只有当修改过的Cache 被替换时才需要写到内存中(一种“懒处理”思想),减少了数据写回内存的频率,提高系统性能。
图解MESI(缓存一致性协议)_第4张图片

  1. 当发生写操作时,数据已经在 CPU Cache 里的话,则把数据更新到 CPU Cache 里,同时标记 CPU Cache 里的这个 Cache Block 为脏(Dirty)的,这个脏的标记代表这个时候,我们 CPU Cache 里面的这个 Cache Block 的数据和内存是不一致的,这种情况是不用把数据写到内存里的。
  2. 当发生写操作时,数据所对应的 Cache Block 里存放的是「别的内存地址的数据」的话,就要检查这个 Cache Block 里的数据有没有被标记为脏的,如果是脏的话,我们就要把这个 Cache Block 里的数据写回到内存,然后再把当前要写入的数据,写入到这个 Cache Block 里,同时也把它标记为脏的;如果 Cache Block 里面的数据没有被标记为脏,就直接将数据写入到这个Cache Block里,然后再把这个Cache Block标记为脏的就好了。

可以看到只有当缓存不命中,同时数据对应的Cache中德Cache Block为脏的时候,才会将数据写回到内存。

多个CPU核心的数据不一致

现在 CPU 都是多核的,由于 L1/L2 Cache 是多个核心各自独有的,那么会带来多核心的缓存一致性(Cache Coherence) 的问题,如果不能保证缓存一致性的问题,就可能造成结果错误。
举个例子:
图解MESI(缓存一致性协议)_第5张图片

  1. 内存中有一个共享变量i = 0。
  2. CPU中的核心1(这里可以理解为核心中的L1 Cache)从内存读取到变量i = 0。
  3. 核心1将i的值修改为1。
  4. 假如使用的是上面的“写回”策略,那核心1对i值的修改只会写到L1 Cache里。只有当核心1中i值对应的Cache Block要被替换时,才会将i写回到内存中。
  5. 此时核心2从内存读取i的值仍然是0。
  6. 这时就会出现两个核心中共享变量i的值不一致的错误!

解决这个问题需要保证两点:写传播、事务串形化。

写传播
某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的Cache,这个称为写传播(Write Propagation)。

事务串形化
某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为事务串形化(Transaction Serialization)。
举个例子来说明:
图解MESI(缓存一致性协议)_第6张图片

  1. 内存中有一个共享变量i = 0。
  2. 核心1先把 i 值变为100,而此时同一时间,核心2把 i 值变为 200。
  3. 上面两个修改都会「传播」到3和4号核心。
  4. 3号核心先收到了1号核心更新数据的事件,再收到2号核心更新数据的事件,因此3号核心看到的 i 是先变成 100,后变成 200。
  5. 而如果4号核心收到的事件是反过来的,则4号核心看到的是 i 先变成 200,再变成100。

虽然是做到了写传播,但是各个 Cache 里面的数据还是不一致的。

那么保证3号核心和4号核心都能看到相同顺序的数据变化,比如 i 都是先变成100,再变成200,这样的过程就是事务的串形化。

要实现事务串形化,要做到两点:

  1. CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心。
  2. 要引入「锁」的概念,如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新。

接下来看看具体是怎么实现以上两点的。

总线嗅探

写传播最常见实现的方式是总线嗅探(Bus Snooping)。

以上面2核心CPU的例子来说明总线嗅探的工作机制:

  1. 当核心1修改了 L1 Cache 中 i 变量的值,通过总线把这个事件广播通知给其他所有的核心。
  2. 每个 CPU 核心都会监听总线上的广播事件,并检查是否有相同的数据在自己的 L1 Cache 里面,如果核心2的 L1 Cache 中有该数据,那么也需要把该数据更新到自己的 L1 Cache。

优点:简单。
缺点:CPU 需要每时每刻监听总线上的一切活动,但是不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载。

MESI(缓存一致性协议)

总线嗅探只是保证了某个 CPU 核心的 Cache 更新数据这个事件能被其他 CPU 核心知道,但是并不能保证事务串形化。

MESI协议基于总线嗅探机制实现了事务串形化,也用状态机机制降低了总线带宽压力,做到了 CPU 缓存一致性。

MESI 协议这4 个字母代表4个状态,分别是: Modified(已修改)、Exclusive(独占)、Shared(共享)、Invalidated(已失效)。

「已修改」状态就是前面提到的脏标记,代表该 Cache Block 上的数据已经被更新过,但是还没有写到内存里。

「已失效」状态表示的是这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据。

「独占」和「共享」状态代表 Cache Block 里的数据是干净的,也就是说,这个时候 Cache Block 里的数据和内存里面的数据是一致性的。

「独占」和「共享」的差别在于,独占状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就可以直接自由地写入,而不需要通知其他 CPU 核心,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。

在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的 Cache ,那么这个时候,独占状态下的数据就会变成共享状态。

「共享」状态代表着相同的数据在多个 CPU 核心的 Cache 里都有,所以当我们要更新 Cache 里面的数据的时候,不能直接修改
而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Block 标记为「无效」状态,然后再更新当前 Cache 里面的数据。

举个具体的例子来看看这四个状态的转换:

  1. 当1号 CPU 核心从内存读取变量 i 的值,数据被缓存在1号 CPU 核心自己的 Cache 里面,此时其他 CPU 核心的 Cache 没有缓存该数据,于是标记 Cache Block 状态为「独占」,此时其 Cache 中的数据与内存是一致的。
  2. 然后2号 CPU 核心也从内存读取了变量 i 的值,此时会发送消息给其他 CPU 核心,由于1号 CPU 核心已经缓存了该数据,此时1和2核心缓存了相同的数据,Cache Block 的状态就会变成「共享」,并且其 Cache 中的数据与内存也是一致的。
  3. 当1号 CPU 核心要修改 Cache 中 i 变量的值,发现数据对应的 Cache Block 的状态是共享状态,则要向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Block 标记为「无效」状态,然后1号 CPU 核心才更新 Cache 里面的数据,同时标记 Cache Line 为「已修改」状态,此时 Cache 中的数据就与内存不一致了。
  4. 如果1号 CPU 核心「继续」修改 Cache 中 i 变量的值,由于此时的 Cache Block 是「已修改」状态,因此不需要给其他 CPU 核心发送消息,直接更新数据即可。
  5. 如果1号 CPU 核心的 Cache 里的 i 变量对应的 Cache Block 要被「替换」,发现 Cache Block 状态是「已修改」状态,就会在替换前先把数据同步到内存。

所以,可以发现当 Cache Block 状态是「已修改」或者「独占」状态时,修改更新其数据不需要发送广播给其他 CPU 核心,这在一定程度上减少了总线带宽压力。

对于不同状态触发的事件操作,可能是来自本地 CPU 核心发出的广播事件,也可以是来自其他 CPU 核心通过总线发出的广播事件。详细流转过程如下:

总结

要想实现缓存一致性,关键是要满足两点:写传播、事物的串行化。

基于总线嗅探机制的 MESI 协议,就满足上面了这两点,因此它是保障缓存一致性的协议。

你可能感兴趣的:(基础,面试,操作系统)