这是我参与更文挑战的第26天,活动详情查看: 更文挑战。
紧接着上一篇你好,请谈谈volatile关键字?(二)
4.4 缓存一致性协议MESI
现在来正式谈谈MESI
,上面说MESI
协议具有四个状态,这四种状态指的是4个单词的首字母,具体包括Modified
、Exclusive
、Shared
、Invalid
,用2个bit表示,几种状态解释如下。
Modified
表示Cache Line
有效,数据只存在当前Cache
中,并且数据是已经被修改了,与主存中的是不一致的。Exclusive
表示Cache Line
有效,数据只存在当前的Cache
中,数据和主存保持一致的。Shared
表示Cache Line
有效,数据并不只是存在当前的Cache
中,被多个Cache
共享,各个Cache
与主存数据都一致。Invalid
表示当前缓存行已经失效。
在MESI
协议中,每个高速缓存的控制器不单单知道自己的读写操作,而且还监听其它高速缓存的读写操作,这个就是嗅探技术,控制器针对当前Cache Line
处于的状态进行不同的监听任务。
4.4.1 状态变化流程
到底是如何监听任务的,我们通过一个简单的例子来分析一下。假设我们现在有一个双核的CPU
,主存里面存储着一个i
的变量,值为1,现在CPU
要做一些运算操作,需要将i
读取到缓存中。
步骤1:图上CPU1
从主存中读取数据到缓存,当前缓存的存储的变量i=1
,缓存行的状态时E
,也就是独占,时刻监听着有没有其它缓存也要从主存中加载该变量。
步骤2:图上CPU2
也试图从主存中读取变量i
,加载到缓存中,CPU1
监听到这个事件,于是CPU1
立刻做出变化,更改状态为S
,CPU2
也同时读取到数据,状态也为S
。此时两个CPU Cache Line
存储的变量i=1
,都在监听有没有事件要使缓存自己置为I
无效态,或者其它缓存要独享变量的请求。
步骤3:图上CPU1
计算完成后,需要修改变量i=2
,缓存管理器先设置Cache Line
的状态为M
修改态,然后发起事件通知其它CPU
,CPU2
收到事件通知,设置Cache Line
的状态为I
无效态。CPU1
监听着其它缓存要读取主内存的事件。CPU2
的缓存行因为状态时无效的,所以缓存行失效。
步骤4:图上,CPU2
运算要用到变量i
,因为存储i
的缓存行失效,去主动同步主内存。CPU1
收到有其它CPU
要读取主存的请求,赶在读取之前,先把修改后的变量同步到主存,同步完以后,主存上的变量i=2
,然后CPU1
缓存管理器设置缓存行的状态为E
。然后按照步骤4,两个CPU
的Cache Line
最后状态都变为S
。
4.4.2 状态变化原则
总的来说,对于CPU
读写操作缓存行,MESI
协议遵循以下的原则:
CPU
读请求:缓存行当前状态处于M E S
状态都可以被读取,处于I
状态下,CPU
只能从主存中读取数据。CPU
写请求:缓存行当前状态处于M E
状态才可以被直接写,处于I
状态下,缓存行已经失效,无法进行读取操作;处于S
状态,能写的前提条件是将其它缓存行设置为无效。
4.4.3 MESI
带来的问题
虽然通过MESI
协议的四种状态和嗅探技术,实现了缓存的一致性,但也带来一些问题。
上面我们谈到,如果CPU
要将计算后的结果写入Cache Line
中,需要发送一个失效的通知给其它存储了相同数据的CPU
,并且必须等到他们的状态变更完成后才能进行相应的写入操作,在整个期间,该CPU
在同步地阻塞的等待,十分影响CPU
的性能。
为了解决阻塞等待的问题,在CPU
中又引入了Store Buffer
,通过这个buffer,CPU
要修改缓存中的值时,只需要将数据写入这个buffer,就可以去执行其它指令了。然后当收到其它CPU
修改指定缓存行的状态为I
无效态以后,再将buffer的数据存储到Cache Line
,然后必要时,再同步到主存中。
这种方案是异步的,解决了CPU
同步等待阻塞的问题。但同时也引入了新的问题。
- 因为是一个异步操作,具体什么时候收到其它
CPU
状态变更的通知是不明确的,所以导致Store Buffer
的数据什么时候写入Cache Line
也是不确定。 - 当未收到其它
CPU
状态变更之前CPU
有可能会来读取数据,首先会从Store Buffer
中读,如果没有,再读Cache Line
,如果还没有,再读主存。
新的问题,带来的巨大的影响就是指令重排序。
我们通过一个例子分析具体是什么问题。
int value =1;
bool finish = false;
void runOnCPU1(){
value = 2;
finish = true;
}
void runOnCPU2(){
if(finish){
assert value == 2;
}
}
我们假设#runOnCPU1
、#runOnCPU2
两个方法分别运行在两个独立的CPU上。 我们很容易想到肯定不会有断言执行。当事实真的如此吗,以下是一种可能的场景。
CPU1
缓存行上缓存了两个关键变量,状态如下:
value | finish | |
---|---|---|
CacheLine 状态 | S | E |
CPU1
在执行#runOnCPU1
方法时,会先把value=2
写入到Store Buffer
中,继续执行finish=true
这条指令,与此同时,也通知了其它存储相同变量的CPU
设置缓存行的状态为I
无效态,并异步的等待执行结果回执。
因为当前存储finish
变量的Cache Line
的状态为E
独占,所以无需通知其它CPU
,立刻就能将finish=true
写入Cache Line
。这个时候CPU2
开始执行#runOnCPU2
方法,会从主存中读取finish
,按照文章上面介绍的状态变化步骤,会轻松读到finish=true
,此时两个CPU
存储finish
的Cache Line
状态都为S
,并且主存的finish=true
。CPU2
继续执行assert value == 2;
这条指令,首先要去从主存中获取value
的值,因为CPU1
修改value
的值还放在Store Buffer
,所以CPU2
取到的值会是1。
也就是说,我们能看到的现象是,在方法#runOnCPU1
中,finish
赋值早于value
的赋值,跟我们预期有差异,这个就是指令重排序带来的可见性问题。
这种可见性问题,可以基于JMM(Java 内存模型)
的内存屏障去解决,恰恰好,这个就是volatile
保证多线程环境下可见性的杀手锏。
篇幅较长,继续阅读请点击【面时莫慌】你好,请谈谈volatile关键字?(四)
哥佬倌,莫慌到走!觉好留个赞,探讨上评论。欢迎关注面试专栏面时莫慌 | Java并发编程,面试加薪不用愁。也欢迎关注我,一定做一个长更的好男人。