并发研究之CPU缓存一致性协议(MESI)

CPU缓存一致性协议MESI

CPU高速缓存(Cache Memory)

CPU为什么要有高速缓存

CPU在摩尔定律的指导下以每18个月翻一番的速度在发展,然而内存和硬盘的发展速度远远不及CPU。这就形成了高性能能的内存和硬盘价格及其昂贵。然而CPU的高度运算须要高速的数据。为了解决这个问题,CPU厂商在CPU中内置了少许的高速缓存以解决I\O速度和CPU运算速度之间的不匹配问题。html

在CPU访问存储设备时,不管是存取数据抑或存取指令,都趋于汇集在一片连续的区域中,这就被称为局部性原理。java

时间局部性(Temporal Locality):若是一个信息项正在被访问,那么在近期它极可能还会被再次访问。程序员

好比循环、递归、方法的反复调用等。数组

空间局部性(Spatial Locality):若是一个存储器的位置被引用,那么未来他附近的位置也会被引用。缓存

好比顺序执行的代码、连续建立的两个对象、数组等。安全

带有高速缓存的CPU执行计算的流程

  1. 程序以及数据被加载到主内存性能

  2. 指令和数据被加载到CPU的高速缓存优化

  3. CPU执行指令,把结果写到高速缓存设计

  4. 高速缓存中的数据写回主内存3d

目前流行的多级缓存结构

因为CPU的运算速度超越了1级缓存的数据I\O能力,CPU厂商又引入了多级的缓存结构。

多级缓存结构

多核CPU多级缓存一致性协议MESI

多核CPU的状况下有多个一级缓存,如何保证缓存内部数据的一致,不让系统数据混乱。这里就引出了一个一致性的协议MESI。

MESI协议缓存状态

MESI 是指4中状态的首字母。每一个Cache line有4个状态,可用2个bit表示,它们分别是:

缓存行(Cache line):缓存存储数据的单元。

状态 描述 监放任务
M 修改 (Modified) 该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 缓存行必须时刻监听全部试图读该缓存行相对就主存的操做,这种操做必须在缓存将该缓存行写回主存并将状态变成S(共享)状态以前被延迟执行。
E 独享、互斥 (Exclusive) 该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。 缓存行也必须监听其它缓存读主存中该缓存行的操做,一旦有这种操做,该缓存行须要变成S(共享)状态。
S 共享 (Shared) 该Cache line有效,数据和内存中的数据一致,数据存在于不少Cache中。 缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
I 无效 (Invalid) 该Cache line无效。

注意:
对于M和E状态而言老是精确的,他们在和该缓存行的真正状态是一致的,而S状态多是非一致的。若是一个缓存将处于S状态的缓存行做废了,而另外一个缓存实际上可能已经独享了该缓存行,可是该缓存却不会将该缓存行升迁为E状态,这是由于其它缓存不会广播他们做废掉该缓存行的通知,一样因为缓存并无保存该缓存行的copy的数量,所以(即便有这种通知)也没有办法肯定本身是否已经独享了该缓存行。

从上面的意义看来E状态是一种投机性的优化:若是一个CPU想修改一个处于S状态的缓存行,总线事务须要将全部该缓存行的copy变成invalid状态,而修改E状态的缓存不须要使用总线事务。

MESI状态转换

理解该图的前置说明:
1.触发事件

触发事件 描述
本地读取(Local read) 本地cache读取本地cache数据
本地写入(Local write) 本地cache写入本地cache数据
远端读取(Remote read) 其余cache读取本地cache数据
远端写入(Remote write) 其余cache写入本地cache数据

2.cache分类:
前提:全部的cache共同缓存了主内存中的某一条数据。

本地cache:指当前cpu的cache。
触发cache:触发读写事件的cache。
其余cache:指既除了以上两种以外的cache。
注意:本地的事件触发 本地cache和触发cache为相同。

上图的切换解释:

状态 触发本地读取 触发本地写入 触发远端读取 触发远端写入
M状态(修改) 本地cache:M
触发cache:M
其余cache:I
本地cache:M
触发cache:M
其余cache:I
本地cache:M→E→S
触发cache:I→S
其余cache:I→S
同步主内存后修改成E独享,同步触发、其余cache后本地、触发、其余cache修改成S共享
本地cache:M→E→S→I
触发cache:I→S→E→M
其余cache:I→S→I
同步和读取同样,同步完成后触发cache改成M,本地、其余cache改成I
E状态(独享) 本地cache:E
触发cache:E
其余cache:I
本地cache:E→M
触发cache:E→M
其余cache:I
本地cache变动为M,其余cache状态应当是I(无效)
本地cache:E→S
触发cache:I→S
其余cache:I→S
当其余cache要读取该数据时,其余、触发、本地cache都被设置为S(共享)
本地cache:E→S→I
触发cache:I→S→E→M
其余cache:I→S→I
当触发cache修改本地cache独享数据时时,将本地、触发、其余cache修改成S共享.而后触发cache修改成独享,其余、本地cache修改成I(无效),触发cache再修改成M
S状态(共享) 本地cache:S
触发cache:S
其余cache:S
本地cache:S→E→M
触发cache:S→E→M
其余cache:S→I
当本地cache修改时,将本地cache修改成E,其余cache修改成I,而后再将本地cache为M状态
本地cache:S
触发cache:S
其余cache:S
本地cache:S→I
触发cache:S→E→M
其余cache:S→I
当触发cache要修改本地共享数据时,触发cache修改成E(独享),本地、其余cache修改成I(无效),触发cache再次修改成M(修改)
I状态(无效) 本地cache:I→S或者I→E
触发cache:I→S或者I →E
其余cache:E、M、I→S、I
本地、触发cache将从I无效修改成S共享或者E独享,其余cache将从E、M、I 变为S或者I
本地cache:I→S→E→M
触发cache:I→S→E→M
其余cache:M、E、S→S→I
既然是本cache是I,其余cache操做与它无关 既然是本cache是I,其余cache操做与它无关

下图示意了,当一个cache line的调整的状态的时候,另一个cache line 须要调整的状态。

M E S I
M × × ×
E × × ×
S × ×
I

举个栗子来讲:

假设cache 1 中有一个变量x = 0的cache line 处于S状态(共享)。
那么其余拥有x变量的cache 二、cache 3等x的cache line调整为S状态(共享)或者调整为 I 状态(无效)。

多核缓存协同操做

假设有三个CPU A、B、C,对应三个缓存分别是cache a、b、 c。在主内存中定义了x的引用值为0。

单核读取

那么执行流程是:
CPU A发出了一条指令,从主内存中读取x。
从主内存经过bus读取到缓存中(远端读取Remote read),这是该Cache line修改成E状态(独享).

双核读取

那么执行流程是:
CPU A发出了一条指令,从主内存中读取x。
CPU A从主内存经过bus读取到 cache a中并将该cache line 设置为E状态。
CPU B发出了一条指令,从主内存中读取x。
CPU B试图从主内存中读取x时,CPU A检测到了地址冲突。这时CPU A对相关数据作出响应。此时x 存储于cache a和cache b中,x在chche a和cache b中都被设置为S状态(共享)。

修改数据

那么执行流程是:
CPU A 计算完成后发指令须要修改x.
CPU A 将x设置为M状态(修改)并通知缓存了x的CPU B, CPU B将本地cache b中的x设置为I状态(无效)
CPU A 对x进行赋值。

同步数据

那么执行流程是:

CPU B 发出了要读取x的指令。
CPU B 通知CPU A,CPU A将修改后的数据同步到主内存时cache a 修改成E(独享)
CPU A同步CPU B的x,将cache a和同步后cache b中的x设置为S状态(共享)。

MESI优化和他们引入的问题

缓存的一致性消息传递是要时间的,这就使其切换时会产生延迟。当一个缓存被切换状态时其余缓存收到消息完成各自的切换而且发出回应消息这么一长串的时间中CPU都会等待全部缓存响应完成。可能出现的阻塞都会致使各类各样的性能问题和稳定性问题。

CPU切换状态阻塞解决-存储缓存(Store Bufferes)

好比你须要修改本地缓存中的一条信息,那么你必须将I(无效)状态通知到其余拥有该缓存数据的CPU缓存中,而且等待确认。等待确认的过程会阻塞处理器,这会下降处理器的性能。应为这个等待远远比一个指令的执行时间长的多。

Store Bufferes

为了不这种CPU运算能力的浪费,Store Bufferes被引入使用。处理器把它想要写入到主存的值写到缓存,而后继续去处理其余事情。当全部失效确认(Invalidate Acknowledge)都接收到时,数据才会最终被提交。
这么作有两个风险

Store Bufferes的风险

第1、就是处理器会尝试从存储缓存(Store buffer)中读取值,但它尚未进行提交。这个的解决方案称为Store Forwarding,它使得加载的时候,若是存储缓存中存在,则进行返回。
第2、保存何时会完成,这个并无任何保证。

value = 3;

void exeToCPUA(){
  value = 10;
  isFinsh = true;
}
void exeToCPUB(){
  if(isFinsh){
    //value必定等于10?!
    assert value == 10;
  }
}

试想一下开始执行时,CPU A保存着finished在E(独享)状态,而value并无保存在它的缓存中。(例如,Invalid)。在这种状况下,value会比finished更迟地抛弃存储缓存。彻底有可能CPU B读取finished的值为true,而value的值不等于10。

即isFinsh的赋值在value赋值以前。

这种在可识别的行为中发生的变化称为重排序(reordings)。注意,这不意味着你的指令的位置被恶意(或者好意)地更改。

它只是意味着其余的CPU会读到跟程序中写入的顺序不同的结果。

顺便提一下NIO的设计和Store Bufferes的设计是很是相像的。

硬件内存模型

执行失效也不是一个简单的操做,它须要处理器去处理。另外,存储缓存(Store Buffers)并非无穷大的,因此处理器有时须要等待失效确认的返回。这两个操做都会使得性能大幅下降。为了应付这种状况,引入了失效队列。它们的约定以下:

  • 对于全部的收到的Invalidate请求,Invalidate Acknowlege消息必须马上发送
  • Invalidate并不真正执行,而是被放在一个特殊的队列中,在方便的时候才会去执行。
  • 处理器不会发送任何消息给所处理的缓存条目,直到它处理Invalidate。

即使是这样处理器已然不知道何时优化是容许的,而何时并不容许。
干脆处理器将这个任务丢给了写代码的人。这就是内存屏障(Memory Barriers)。

写屏障 Store Memory Barrier(a.k.a. ST, SMB, smp_wmb)是一条告诉处理器在执行这以后的指令以前,应用全部已经在存储缓存(store buffer)中的保存的指令。

读屏障Load Memory Barrier (a.k.a. LD, RMB, smp_rmb)是一条告诉处理器在执行任何的加载前,先应用全部已经在失效队列中的失效操做的指令。

void executedOnCpu0() {
    value = 10;
    //在更新数据以前必须将全部存储缓存(store buffer)中的指令执行完毕。
    storeMemoryBarrier();
    finished = true;
}
void executedOnCpu1() {
    while(!finished);
    //在读取以前将全部失效队列中关于该数据的指令执行完毕。
    loadMemoryBarrier();
    assert value == 10;
}

如今确实安全了。完美无暇!

后记

然而,对于程序员来讲简直是一个灾难。不想和平台耦合咱们要跨平台。Write One,Run Everywhere!
幸亏java解决了这个问题,至于如何解决的请关注JMM(JavaMemoryMode)与物理内存相爱相杀。

引用文章

深刻Java内存模型