CPU缓存一致性协议MESI

一、CPU高速缓存(Cache Memory)

CPU为何要有高速缓存

CPU在摩尔定律的指导下以每18个月翻一番的速度在发展,然而内存和硬盘的发展速度远远不及CPU。这就造成了高性能能的内存和硬盘价格及其昂贵。然而CPU的高度运算需要高速的数据。

为了解决这个问题,CPU厂商在CPU中内置了少量的高速缓存以解决I\O速度和CPU运算速度之间的不匹配问题。
在CPU访问存储设备时,无论是存取数据抑或存取指令,都趋于聚集在一片连续的区域中,这就被称为局部性原理。

时间局部性(Temporal Locality):如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。比如循环、递归、方法的反复调用等。

空间局部性(Spatial Locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。比如顺序执行的代码、连续创建的两个对象、数组等。

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

  1. 程序以及数据被加载到主内存
  2. 指令和数据被加载到CPU的高速缓存
  3. CPU执行指令,把结果写到高速缓存
  4. 高速缓存中的数据写回主内存

在这里插入图片描述

目前流行的多级缓存结构

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

在这里插入图片描述

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

背景

早期的时候,采用的是总线锁的方式,而锁住总线之后,其他核自然也是没办法访问内存中的其他数据。这样,多核的CPU反而成了单核。所以说,早期多核CPU的能力没有被完全发挥出来。后面就采用了缓存一致性协议,这种协议是一个规范,MESI是一种实现。

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

MESI协议缓存状态

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

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

在这里插入图片描述
注意:
对于M和E状态而言总是精确的,他们在和该缓存行的真正状态是一致的,而S状态可能是非一致的。如果一个存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。
在这里插入图片描述

MESI状态转换

在这里插入图片描述
理解该图的前置说明:

1.触发事件
在这里插入图片描述
2.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 状态(无效)。

多核缓存协同操作

假设有三个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状态(无效) [这些通知消息是通过bus总线传递的,因为CPU之间是通过bus总线通信的]
CPU A 对x进行赋值。

如果多个线程同时对一个变量加锁,那么怎么判断谁获取到锁呢?

由bus总线来最终抉择谁获取到锁。因为CPU之间的通信就是通过bus总线来进行的。

在这里插入图片描述
同步数据

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

在这里插入图片描述

缓存行伪共享

什么是伪共享?

CPU缓存系统中是以缓存行(cache line)为单位存储的。目前主流的CPU Cache 的Cache Line 大小都是64Bytes。在多线程情况下,如果需要修改“共享同一个缓存行的变量”,就会无意中影响彼此的性能,这就是伪共享(False Sharing)。

举个例子: 现在有2个long 型变量 a 、b,如果有t1在访问a,t2在访问b,而a与b刚好在同一个cache line中,此时t1先修改a,将导致b被刷新!
在这里插入图片描述

怎么解决伪共享?

思考:我们能想到的思想是:既然一个缓存行占64byte,假如我要存储一个Long类型的变量而不想产生伪共享,那么我可以只让一个缓存行存储这一个变量就好了呀。

那我们是否可以考虑存储的时候我定义一个Long A, 然后同时定义7个Long类型的无效的变量。这样公共8个long类型的变量就是64byte。这样我只关注Long A,其他7个变量就算是伪共享我也不用关注。

Java8中新增了一个注解:@sun.misc.Contended。加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在jvm启动时设置 -XX:-RestrictContended 才会生效。

 @sun.misc.Contended
 public final static class TulingVolatileLong {
 
 public volatile long value = 0L;
 //public long p1, p2, p3, p4, p5, p6;
}

三、MESI优化和他们引入的问题

对于超过一个缓存行(64byte)的变量如何解决同步问题?

MESI是通过锁定缓存行来失效脏数据的,缓存行锁定是原子的,即一个缓存行是MESI锁定的最小单元,所以就无法保证能同时锁定多个缓存行,因为其他CPU有可能会竞争这个锁 ,即无法原子性的锁定多个缓存行 。那么如果一个对象的大小超过了64字节,假设为128字节,此时一个缓存行存储不下,只能跨缓存行存储。此时 缓存一致性协议MESI就会失效,缓存同步机制就会升级为为总线锁

缓存行大多数时候都是可用的。


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

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

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

Store Bufferes

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

Store Bufferes的风险

第一、就是处理器会尝试从存储缓存(Store buffer)中读取值,但它还没有进行提交。这个的解决方案称为Store Forwarding,它使得加载的时候,如果存储缓存中存在,则进行返回。

第二、保存什么时候会完成,这个并没有任何保证。

如果某一个CPU对一个变量进行了修改,首先会将修改后的值写入到Store Bufferes中去( 此时修改后的值还没有写入到自己的缓存行中,需要等待其他CPU收到消息确认并将变量状态设置为I后(【问题来了,将自己缓存中的变量状态设置为I之后,如何将这个消息通知给其他CPU呢?这里会将失效的数据加入到一个失效度队列中去,然后采用消息通知的形式发送给其他CPU 】),才会将修改后的值写入到自己的缓存行,然后再写入到主内存中去。但是何时同步回主内存中去,这个时机是不确定的。 )。然后会将这个修改消息发送到bus总线,其他的所有的加载了该变量的CPU确定并使自己缓存中的这个变量都设置成I之后,这个更新后的值才会被写入到主内存中去。

 value = 3void 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;
}

Volatile为什么无法保证原子性?

假如CPU1和CPU2同时加载了一个变量counter=0,然后分别将这两个变量读到了寄存器中。此时发生了一下行为:

1、CPU1和CPU2都对counter 执行++操作,但是CPU1先执行完成;

2、此时变量counter的状态从S被修改成M,此时CPU1将修改信息发送到bud总线并将修改后的值写入到主内存。CPU2监听bus总线得知counter已经失效,此时将L1缓存中的counter设置为I。

3、但是现在存在的问题就是,counter = 0和counter++这两条指令已经被读取到了CPU的寄存器中,马上就要执行了。而MESI协议也是没办法影响到寄存器中的内容的。而且CPU2执行完后还会将值写入到主内存中去。这样一来,变量的值就混乱了。也就是产生了线程安全问题。
在这里插入图片描述

四、图片详解

在这里插入图片描述

在这里插入图片描述

理解volatile底层汇编中的lock前缀

在这里插入图片描述
我们将java代码翻译成汇编,可以看到操作volatile变量的地方有一个lock前缀。

lock前缀会出触发总线锁,并且对应用程序有用。

lock前缀会触发硬件缓存锁定机制(总线锁或者缓存一致性协议)

由于技术限制,早期使用总线锁来保证缓存一致性,

内存总线:在CPU和内存条之间有一座桥叫 I/O桥。I/O桥两端两端分别使用系统总线和内存总线来连接CPU和内存条。
在这里插入图片描述
多核CPU中,如果某个核要读写主内存中的一个变量,那么必须先获取到总线的使用权,才可以通过主线去访问这个变量。
在这里插入图片描述
而锁住总线之后,其他核自然也是没办法访问内存中的其他数据。这样,多核的CPU反而成了单核。所以说,早期多核CPU的能力没有被完全发挥出来。

在修改内存操作时,使用lock前缀去调用加锁的读-修改-写操作(原子的)。这种机制多用于多处理器系统中处理器之间进行可靠的t通讯。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值