MESI介绍
1. 背景#
现代处理器的发展历史上,CPU的性能和内存性能差距逐渐拉大,为了解决这一问题,CPU设置了多级缓存结构,其中较为典型的有L1,L2,L3高速缓存。 其中L1高速缓存具有和寄存器差不多的速度。L1,L2,L3缓存都位于芯片内部,这些缓存我们统称为Cache,下述就不再区分了。由于Cache位于CPU内部,意味着对于多个CPU,缓存之对于所在的CPU可见,那么对于每个CPU在处理数据的时候就不免会造成缓存和主存的数据不一致的问题,为了解决这个问题,CPU厂商提出了两种解决方案:
1.总线锁定:当某个CPU处理数据时,通过锁定系统总线或者时内存总线,让其他CPU不具备访问内存的访问权限,从而保证了缓存的一致性
2.缓存一致性协议(MESI):缓存一致性协议也叫缓存锁定,缓存一致性协议会阻止两个以上CPU同时修改缓存了相同主存数据的缓存副本
线锁定开销太大,现代的处理器已经很少采用这种方式保证缓存数据一致性,重点分析一下MESI协议
2. 高速缓存结构#
在介绍缓存一致性协议之前有必要先介绍一下高速缓存的数据的组织形式。因为把高速缓存的数据结构介绍清楚,有助于理解下面MESI协议。
高速缓存的结构和jdk中的HashMap的结构有点类似,都是采用数组进行分桶,之后采用拉链法挂到对应的桶上,具体结构如下:
链表中节点名称叫做cache entry,下面看一下cache entry的结构:
其中tag用来定位cache entry,data block用来保存缓存的数据,flag就是重点了,这个标识就是用来标注当前节点的状态,对应下面要介绍的MESI协议的四种状态,分别位M,E,S,I。
3. MESI协议#
3.1 介绍#
MESI是四个单词的首字母缩写,Modified修改,Exclusive独占,Shared共享,Invalid无效
M:表示当前CPU的高速缓存中的变量副本是独占的,而且和主存中的变量值不一致,而且别的CPU的flag不可能是这个状态。如果别的CPU想要读取变量的值,不能直接读主内存中的值,而是需要将处于M状态的变量刷新回主内存才可以。
E:表示当前CPU的高速缓存中的变量副本是独占的,别的CPU高速缓存中该变量的副本不能处于该状态,但是,处于E状态的高速缓存变量的值和主内存中的变量值是一致的。
S:处于S状态表示CPU中的变量副本和主存中数据一致,而且多个CPU都可以处于S状态,举例,当多个CPU读取主内存的值的时候高速缓存的flag就处于S状态。
I:表示当前CPU的高速缓存的变量副本处于不合法状态,不可以直接使用,需要从主内存重新读取,flag的初始状态就是I。
3.2 MESI状态转换#
-
触发事件
触发事件 描述 本地读取(Local read) 本地cache读取本地cache数据 本地写入(Local write) 本地cache写入本地cache数据 远端读取(Remote read) 其他cache读取本地cache数据 远端写入(Remote write) 其他cache写入本地cache数据 -
cache分类
前提:所有的cache共同缓存了主内存中的某一条数据。
本地cache:指当前cpu的cache。
触发cache:触发读写事件的cache。
其他cache:指既除了以上两种之外的cache。
注意:本地的事件触发 本地cache和触发cache为相同。
-
上图的切换解释:
下图示意了,当一个cache line的调整的状态的时候,另外一个cache line 需要调整的状态。
例如:
假设cache 1 中有一个变量x = 0的cache line 处于S状态(共享)。
那么其他拥有x变量的cache 2、cache 3等x的cache line调整为S状态(共享)或者调整为 I 状态(无效)。
3.3 MESI协议举例#
1.当CPU A将主存中的x cache line读入缓存中时,此时X副本的状态为E独占。
2.当CPU B将主存中的X cache line读入缓存中时,AB同时嗅探总线,得知X cache line不止一个副本,此时X的状态变为S共享
3,当CPU A将CACHE A中的x cache line修改为1后,Cache A中的X cache line 的状态变为M修改,并发送消息给CPU B,CPU将X cache line的状态变为I无效
4.当CPU A确认所有CPU缓存中的都提交了I无效状态,将修改后的值刷新到主存中,此时主存中的X变为了1,此时Cache A中的x cache line变为E独享
5.当CPU B需要用到X,发出读取X指令,于是读取主存中的x,于是重复第二步
3.4 MESI性能优选#
-
在上面的例子中,有这么一个场景,就是CPU A要修改Cache A的值,这个时候他需要先发送Invalidate请求,等到别的CPU都返回了ack,才可以真正的开始修改缓存中的值。这个过程其实有两个地方要等待。
CPU A需要等待别的CPU返回ack,这个过程浪费时间
CPU B需要先将Cache B的状态更新为I,之后再返回ack,这个过程也非常浪费时间 -
所以针对这两点,从硬件级别就做了两点优化,引入了store buffer(写缓冲区)和Invalidate queues(失效队列),对应于上面例子中的优化具体如下:
CPU A将X的值写入到写缓冲区,之后直接发送Invalidate请求,然后CPU就去做别的事情,当所有的ack都收到之后,再把写缓冲区中的值更新到高速缓存。
CPU B收到CPU A发送的invalidate请求之后,并不会直接去修改Cache B的状态,而是将请求信息放入Invalidate Queues中,等CPU有空闲了再处理。 -
以上两个存储结构的引入的确可以解决MESI协议效率低的问题,但是由于延迟执行却带来了新的问题,就是常见的可见性和有序性的问题,下面就举例分析一下引入上面两个存储结构之后导致的可见性和有序性的问题。
-
可见性问题:
如果CPU A修改了X的值,但是并没有直接刷新回高速缓存,这个时候如果CPU A或者CPU B要使用X的值,对于CPU A来说,他的缓存状态时S,说明和内存中的状态一致,所以就直接使用了旧的值。对于CPU B来说,他的状态也是S,他也会直接使用这个值,但是其实这个时候X的值已经被CPU A修改过了,但是却没有生效。
public class Test { static int a = 1; static int c = 1; public static void main(String[] args) { new Thread(()->{ a = 2; int b = c; }).start(); } }
3.5 内存屏障#
-
上面提到了使用store buffer和invalidate queues之后会有可见性和有序性的问题,那如何解决这些问题,就是下面要介绍的内存屏障来解决。
-
内存屏障(memory barrier)是一个CPU指令。其基本作用:
阻止屏障两边的代码发生指令重排
强制将写缓冲区/高速缓存的数据刷新回主内存,并使得相应的缓存中的数据失效 -
在详细介绍内存屏障之前需要先介绍两个指令
store指令:将数据刷新到主内存中
load指令:从主内存中重新加载最新的数据 -
内存屏障在不同的硬件有不同的实现,本文介绍一下x86的内存屏障实现
Store Barrier:在x86中是sfence指令实现的,强制该屏障之前的store指令都执行完才可以执行sfence指令,然后才可以执行屏障之后的store指令。
Load Barrier:在x86中是lfence指令实现的,强制该屏障之前的load指令都执行完才可以执行Ifence指令,然后才可以执行屏障之后的load指令。
Full Barrier:在x86中是mfence指令实现的,该指令相当于sfence和Ifence两个指令的功能。
-
jvm为了屏蔽硬件的差异,定义了自己的内存屏障,其底层是使用硬件的内存屏障。
LoadLoad内存屏障:相当于上面介绍的Load Barrier内存屏障的作用。
StoreStore内存屏障:相当于上面介绍的Store Barrier内存屏障的作用。
StoreLoad内存屏障:相当于上面介绍的Full Barrier内存屏障,这个是最全能的,相当于其他三个内存屏障的功能,但是相应的开销也更大。
LoadStore内存屏障:这个在上面没有对应的硬件指令,不清楚jvm如何实现的,不过功能是在LoadStore内存屏障之前Load指令执行完之后才可以执行LoadStore,之后才可以执行后面的Store指令。
-
介绍完内存屏障,那可见性和有序性如何解决呢?
-
可见性问题:
public class Test { static int a = 1; static int c = 1; public static void main(String[] args) { new Thread(()->{ a = 2; StoreLoad();//伪代码 int b = c; }).start(); } }
还是上面介绍的例子,如果在a = 2之后加入StoreLoad指令,就可以保证a的值从写缓冲区写入到高速缓存,如果硬件同时要求写入主内存,还会刷新回主内存,之后才会执行int b = c;这个load操作,这样就可以保证可见性。
-
有序性问题:
public class Test { static boolean isRunning = true; public static void main(String[] args) { new Thread(()->{ isRunning = false; StoreLoad(); //伪代码 while (isRunning){ System.out.println("执行了"); } }).start(); } }
本例中可能会发生指令重排的地方就是isRunning = false;赋值操作还没有执行,先执行了下面的while,当然CPU可以保证最终结果的正确性,所以这里并没有出现问题,如果一定保证代码不发生指令重排,可以在isRunning = false;下面加一个StoreLoad指令,防止指令重排序。其实有序性问题有一个著名的例子,就是单例模式使用double check进行初始化单例的时候,在高并发的场景下依然可能会出问题,必须使用volatile。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· 周边上新:园子的第一款马克杯温暖上架
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· Ollama——大语言模型本地部署的极速利器
· DeepSeek如何颠覆传统软件测试?测试工程师会被淘汰吗?
· 使用C#创建一个MCP客户端