CPU中的MESI协议(Intel)

目录

CPU缓存一致性

MESI 协议

MESI优化带来的性能问题

处理器执行时的乱序优化引发的问题

as-if-serial规则

happens-before规则

as-if-serial规则和happens-before规则的区别

Memory Barriers 内存屏障,解决乱序优化问题


CPU缓存一致性

要解决CPU缓存一致性这一问题,就需要一种机制,来同步两个不同核心里面的缓存数据。要实现的这个机制的话,要保证做到下面这 2 点:

  • 第一点,某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为写传播(*Wreite Propagation*)
  • 第二点,某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为事务的串形化(*Transaction Serialization*)

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

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

要实现写传播,要做到当某个 CPU 核心更新了 Cache 中的数据,要把该事件广播通知到其他核心。最常⻅实 现的方式是总线嗅探(Bus Snooping)。

还是以前面的 i 变量例子来说明总线嗅探的工作机制,当 A 号 CPU 核心修改了 L1 Cache 中 i 变量的 值,通过总线把这个事件广播通知给其他所有的核心,然后每个 CPU 核心都会监听总线上的广播事件,并 检查是否有相同的数据在自己的 L1 Cache 里面,如果 B 号 CPU 核心的 L1 Cache 中有该数据,那么也需 要把该数据更新到自己的 L1 Cache。

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

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

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

MESI 协议

MESI 协议其实是 4 个状态单词的开头字母缩写,分别是:

  • Modified,已修改
  • Exclusive,独占
  • Shared,共享
  • Invalidated,已失效

四个状态来标记 Cache Line缓存行的不同状态

「已修改」状态就是前面提到的脏标记,代表该 Cache Block 上的数据已经被更新过,但是还没有写到内存里。而「已失效」状态,表示的是这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据。

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

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

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

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

事实上,整个 MESI 的状态可以用一个有限状态机来表示状态流转。还有一点,对于不同状态触发的事件操作,可能是来自本地 CPU 核心发出的广播事件,也可以是来自其他 CPU 核心通过总线发出的广播事件。下图即是 MESI 协议的状态图:

下面的四个状态变化,指的是,当前CPU某个核中的指定 Cache Line的状态变化!

MESI 协议的四种状态之间的流转过程,汇总成了下面的表格,可以更详细的看到每个状态转换的原因:

Local Read:当前核读自己

Local Write:当前核写自己

Remote Read:其他核来读

Remote Write:其他核来写

cpu cache数据的写入内存

事实上,数据不止有读取还有写入,CPU对数据进行一系列操作后,

将数据写入CPU的cache,内存和cache的数据就不同了,也叫脏了Dirty,终究是要需要把cache同步到内存中。

问题的关键就在于在什么时机去把数据写到内存?

一般来讲有以下两种策略

写直达(Write Through)

保持内存与 Cache 一致性最简单的方式是,把数据同时写入内存和 Cache 中,这种方法称为写直达 。

写直达法很直观,也很简单,但是问题明显,无论数据在不在 Cache 里面,每次写操作都会写回到内存, 这样写操作将会花费大量的时间,无疑性能会受到很大的影响。

写回(Write Back写回)

由于写直达的机制会有性能问题,所以产生了写回(Write Back)的方法

在写会机制中,当发生写操作时,新的数据仅仅被写入 Cache Block 里,只有指定某些场景才需要写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能。

在MESI协议中,需要把CPU内核中缓存数据刷到内存的场景:

想要Read 或者 Write 位于其他核心处于Modified的数据

1、A核心的Cache数据处于Invalid失效状态,A想要读取的数据位于B核心,并且B核心的状态是Modified已修改的时候,需要将B的Cache Line数据刷到内存
2、A核心Cache数据处于Modified已修改状态,B核心对缓存来 Read 和 Write,都需要A核心先把缓存数据刷到内存中去。

MESI优化带来的性能问题

各个 CPU 缓存行的状态是通过消息传递来进行的。如果CPU0要对一个在缓存中共享的变量(S状态)进行写入,首先需要发送一个失效的消息给到其他缓存了该数据的 CPU。并且要等到他们的确认回执,CPU0 在这段时间内都会处于阻塞状态。这对于频率很高的CPU来说,简直不能接受。执行过程如下

Store Buffer

为了解决此问题,cpu层面引入了Store Buffer。此时的执行过程为:

CPU0只需要在写入共享数据(独占数据不用)时,

1、发送 invalidate 消息给其他核

2、直接把数据写入到store buffer 中,同时,(然后继续去处理其他指令,因为数据还没刷到cache里面,就去搞其他事情,这个就是处理器执行时的乱序优化

3、收到其他所有CPU发送了invalidate acknowledge消息时,再将 store buffer 中的数据数据存储至 cache line中。最后再从缓存行同步到主内存。

另外一个CPU1核收到Invalid消息时,

1、会把消息写入自身的Invalidate Queue中,

2、随后异步将其设为Invalid状态。

和Store Buffer不同的是,当前CPU1核心使用Cache时并不扫描Invalidate Queue部分,所以可能会有极短时间的脏读问题。这里的Store Buffer和Invalidate Queue的说法是针对一般的SMP架构来说的,不涉及具体架构。

Store-buffer Forwarding

引入store buffer之后又带了新的问题,同一个 CPU 在顺序执行指令的过程中,有可能出现,前面的已经执行写入变更,但对后面的代码逻辑不可见

a=1
b=a+1
assert(a==2)
复制代码

cpu对a赋值为1,此时a变量进入到store buffer,缓存中的a还是等于0,此时执行b=a+1得到的结果是b=1,assert不通过。

解决方案就是采用Store Forwarding

对于同一个 CPU 而言,在读取 a 变量的时候,如若发现 Store Buffer中有尚未写入到缓存的数据 a,则直接从 Store Buffer 中读取。这就保证了,逻辑上代码执行顺序,也保证了可见性。

处理器执行时的乱序优化引发的问题

通过 Store-Buffer Forwarding 解决了单个 CPU 执行顺序性和内存可见性问题,但是在全局多 CPU 的环境下,这种内存可见性恐怕就很难保证了。

a = 0;
isFinish = false;
void cpu0run()
{
 	a = 1;
 	isFinish = true;
 	// 其他操作
}

void cpu1run()
{
 	while (isFinish) continue;
 	assert(a == 1);
 	// 其他操作
}

假设上面的 cpu0run方法被CPU0执行,cpu1run方法被CPU1执行,也就是我们常说的多线程环境。试想,即便在多线程环境下,cpu0run 和 cpu1run 如若严格按照理想的顺序执行,是无论如何都不会出现 assert failed 的情况的。

但是有概率会出现了 assert failed ,来分析整个过程,假设 a初始值为0,isfinsh为false,a被CPU0和CPU1共同持有(S状态),isfinsh被CPU0 独占(E状态);

CPU 0 处理 a=1 之前发送 Invalidate 消息给 CPU1 ,并将其放入 Store Buffer ,尚未及时刷入缓存;

CPU 0 转而处理 isFinish = true ,由于isFinish是独占锁E,此时 isfinsh = true 直接被刷入缓存; CPU 1 发出 Read 消息读取 isfinsh 的值,发现 isfinsh = true ,跳出 while 语句;

CPU 1 发出 Read 消息读取 a 的值,发现 a 却为旧值 0,assert failed。

在日常开发过程中也是完全有可能遇到上面的情况,由于 a 的变更对 CPU1 不可见,虽然执行指令的时序没有真正被打乱,但对于 CPU1 来说,这造成了

isfinsh = true 先于 a=1 执行的假象,这种看是乱序的问题,通常称为 “重排序”。当然上面所说的情况,只是指令重排序的一种可能。

as-if-serial规则

as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。 编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。 但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。示例代码如下:

int a=1;
int b=2;
int c=a+b;

a和c之间存在数据依赖关系,同时b和c之间也存在数据依赖关系。因此在最终执行的指令序列中,c不能被重排序到A和B的前面(c排到a和b的前面,程序的结果将会被改变)。但a和b之间没有数据依赖关系,编译器和处理器可以重排序a和b之间的执行顺序。

happens-before规则

Happens-before的前后两个操作不会被重排序且后者对前者的内存可见。

  • 程序次序法则:线程中的每个动作A都happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后。
  • 监视器锁法则:对一个监视器锁的解锁 happens-before于每一个后续对同一监视器锁的加锁。
  • volatile变量法则:对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。
  • 线程启动法则:在一个线程里,对Thread.start的调用会happens-before于每个启动线程的动作。
  • 线程终结法则:线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或Thread.isAlive返回false。
  • 中断法则:一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断。
  • 终结法则:一个对象的构造函数的结束happens-before于这个对象finalizer的开始。
  • 传递性:如果A happens-before于B,且B happens-before于C,则A happens-before于C

代码demo : https://blog.csdn.net/Sword52888/article/details/126079993

as-if-serial规则和happens-before规则的区别

  1. as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
  2. as-if-serial语义给编写单线程程序的程序员创造了一个幻觉:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻觉:正确同步的多线程程序是按happens-before指定的顺序来执行的。
  3. as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

Memory Barriers 内存屏障,解决乱序优化问题

CPU级别内存屏障其作用有两个:

  1. 保证特定操作的执行顺序,防止指令之间的重排序
  2. 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)

指令重排中Load和Store两种操作会有Load-Store、Store-Load、Load-Load、Store-Store这四种可能的乱序结果。

从硬件层面很难去知道软件层面上的这种前后依赖关系,所以没有办法通过某种手段自动去解决。所以在 CPU 层面提供了 memory barrier(内存屏障)的指令,从硬件层面来看这个 memroy barrier 就是 CPU flush store bufferes 中的指令。软件层面可以决定在适当的地方来插入内存屏障。

什么是内存屏障?

从前面的内容基本能有一个初步的猜想,内存屏障就是将 store bufferes 中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。

X86 的 memory barrier 指令包括 lfence(读屏障) sfence(写屏障) mfence(全屏障)

  • sfence ,实现Store Barrior(写屏障) 会将store buffer中缓存的修改刷入L1 cache中,(我的理解,就是必须等到其他CPU核心的Invalid ack之后,再flush到L1 cache中,然后再继续往下走)使得其他cpu核可以观察到这些修改,而且之后的写操作不会被调度到之前,即sfence之前的写操作一定在sfence完成且全局可见;
  • lfence ,实现Load Barrior(读屏障) 会将invalidate queue失效,强制读取入L1 cache中,(上面说了有几率出现 脏读)而且lfence之后的读操作不会被调度到之前,即lfence之前的读操作一定在lfence完成(并未规定全局可见性);
  • mfence ,实现Full Barriorr(全屏障) 同时刷新store buffer和invalidate queue,保证了mfence前后的读写操作的顺序,同时要求mfence之后写操作结果全局可见之前,mfence之前写操作结果全局可见;
  • lock 用来修饰当前指令操作的内存只能由当前CPU使用,若指令不操作内存仍然由用,因为这个修饰会让指令操作本身原子化,而且自带Full Barrior效果;还有指令比如IO操作的指令、exch等原子交换的指令,任何带有lock前缀的指令以及CPUID等指令都有内存屏障的作用。

有了内存屏障以后,对于上面这个例子,我们可以这么来改,从而避免出现可见性问题。

a = 0;
isfinsh = false;
void cpu0run()
{
 	a = 1;
 	// 写屏障,上面a=1,收到其他核心的Invalid ack之后,(此时其他核的数据a放在队列Invalid queue中)再从store buffer刷到L1 cache之后,再往下操作
 	Store Memory Barrier();
 	isfinsh = true;
 	// 其他操作
}

void cpu1run()
{
 	while (isfinsh) continue;
    Load Memory Barrier();
 	// 读屏障,使得CPU1把Invalid queue中的失效数据刷到L1 cache中,此时发现a已经失效,那么去主存读。 由于是CPU0的a数据是Modified状态,所以会让cpu1把a刷到主存中去
 	assert(a == 1);
 	// 其他操作
}

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值