计算机的缓存一致性

计算机在运行程序时,每条指令都是在CPU中执行的,在执行过程中势必会涉及到数据的读写。我们知道程序运行的数据是存储在主存中,这时就会有一个问题,读写主存中的数据没有CPU中执行指令的速度快,如果任何的交互都需要与主存打交道则会大大影响效率,所以就有了CPU高速缓存(Cache Memory)。CPU高速缓存为某个CPU独有,只与在该CPU运行的线程有关。
有了CPU高速缓存虽然解决了效率问题,但是它会带来一个新的问题:数据一致性在程序运行中,会将运行所需要的数据复制一份到CPU高速缓存中,在进行运算时CPU不再与主存打交道,而是直接从高速缓存中读写数据,只有当运行结束后才会将数据刷新到主存中。
解决缓存一致性方案有两种:

  1. 通过在总线加LOCK#锁的方式
  2. 通过缓存一致性协议

 

CPU高速缓存(Cache Memory)

存在的意义

CPU高速缓存是为了解决CPU速率和主存访问速率差距过大问题。

  • CPU:根据摩尔定律,CPU会以每18个月的时间将访问速度翻一番,相当于每年增长60%。
  • 内存:内存的访问速度虽然也在不断增长,却远没有这么快,每年只增长 7% 左右

到今天来看,一次内存的访问,大约需要 120 个 CPU Cycle,这也意味着,在今天,CPU 和内存的访问速度已经有了 120 倍的差距。

 

因此引入了“高速缓存”,CPU厂商在CPU中内置了少量的高速缓存以解决I\O速度和CPU运算速度之间的不匹配问题。(高速缓存是插在CPU寄存器和主存之间的缓存存储器)

  • 高速缓存(CPU Cache):用于平衡 CPU 和内存的性能差异,分为 L1/L2/L3 Cache。其中 L1/L2 是 CPU 私有,L3 是所有 CPU 共享。
  • 缓存行(Cache Line):高速缓存的最小单元,一次从内存中读取的数据大小。常用的 Intel 服务器 Cache Line 的大小通常是 64 字节。

 

存储器层次结构

存储器在计算机内是有层次,就像一个金字塔,塔顶的存储器速度极高,但容量很小,越往下,速度越慢,但容量越大。

CPU缓存一致性协议(MESI)_加载

缓存如何提高效率

计算机程序运行遵循局部性原则。局部性原理是指程序在执行时呈现出局部性规律,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。

 

局部性原理又表现为:时间局部性和空间局部性。

  • 时间局部性(Temporal Locality):指如果某条指令一旦被执行,很有可能不久后还会再次被执行;如果某个数据一旦被访问了,很有可能不久之后还会再次被访问。如:循环、递归等。
  • 空间局部性(Spatial Locality):指如果某个存储单元一旦被访问了,很有可能不久后它附件的存储单元也会被访问。如连续创建多个对象、数组等。

 

具有良好局部性的程序比差的程序更多的倾向于从存储器层次结构较高层次处访问数据,因此运行的更快,尤其是执行大数据量的算术运算。

 

单核下高速缓存的CPU执行流程

  • 1.程序和数据都被加载到主内存中
  • 2.执行指令和数据被加载到CPU的高速缓存中,进行逻辑处理
  • 3.CPU执行指令再将处理后的结果写到CPU的高速缓存中
  • 4.CPU的高速缓存再将数据写回(更新)到主内存中

列举缓存结构图:

CPU缓存一致性协议(MESI)_主存_02

 

多级缓存结构

高速缓存是插在CPU寄存器和主存之间的缓存存储器,称为L1高速缓存,基本是由SRAM(static RAM)构成,访问时大约需要4个始终周期。刚开始只有L1高速缓存,后来CPU和主存访问速度差距不断增大,在L1和主存之间增加了L2高速缓存,可以在10个时钟周期内访问到。现代CPU又增加了一个更大的L3高速缓存,可以在大约50个时钟周期内访问到它。

列举多级缓存结构图:

CPU缓存一致性协议(MESI)_加载_03

 

多核CPU多级缓存下的MESI

MESI的缓存状态

CPU中每个缓存行(Caceh line)使用4种状态进行标记,使用2bit来表示:

状态

描述

监听任务

状态转换

M 修改 (Modified)

该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。

缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。

当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。

E 独享、互斥 (Exclusive)

该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。

缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。

当CPU修改该缓存行中内容时,该状态可以变成Modified状态

S 共享 (Shared)

该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。

缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。

当有一个CPU修改该缓存行时,其它CPU中该缓存行可以被作废(变成无效状态 Invalid)。

I 无效 (Invalid)

该Cache line无效。

注意: 对于M和E状态而言总是精确的,他们在和该缓存行的真正状态是一致的,而S状态可能是非一致的。

 

MESI状态间的转换

MESI状态转换图:

CPU缓存一致性协议(MESI)_数据_04

  • 本地读取(Local Read): 本地cache读取本地cache中的数据
  • 远端读取(Remote Read): 其它cache读取本地cache中的数据
  • 本地写入(Local Write): 本地cache将数据写入本地cache中
  • 远端写入(Remote Write): 其它cache将数据写入本地cache中

 

装换说明

第一:

某个CPU(CPU A)发起本地写请求(Local Write),比如对某个内存地址的变量赋值,如果此时所有CPU的Cache中都没加载此内存地址,即此内存地址对应的Cache Line为无效状态(Invalid),则CPU A中的Cache Line保存了最新内存变量值以后,其状态被修改为Modified

随后,如果CPU B发起对同一个变量的读操作(Remote Read),则CPU A在总线上嗅探到这个读请求以后,先将Cache Line里修改过的数据回写(Write Back)到Memory中,然后在内存总线上放一份Cache Line的拷贝作为应答,最后再将自身的Cache Line的状态修改为Shared,由此产生的结果是CPU A与CPU B里对应的Cache Line的状态都为Shared。

 

第二:

在第一点的基础上,CPU A发起本地写请求导致自身的Cache Line状态变为Modified以后,如果此时CPU B发起同一个内存地址的写请求(Remote Write),则我们看到状态图里此时CPU A的Cache Line状态为Invalid

其原因是如下:CPU B此时发出的是一个特殊的请求——“读并且打算修改数据”(read with intent to modify),当CPU A从总线上嗅探到这个请求后,会先阻止此请求并取得总线的控制权(Takes control of bus),随后将Cache Line里修改过的数据回写(Write Back)到Memory中,再将此Cache Line的状态修改为Invalid(这是因为其他CPU要改数据,所以没必要改为Shared了)。

与此同时,CPU B发现之前的请求并没有得到响应,于是重新再发起一次请求,此时由于所有CPU的Cache里都没有内存副本了,所以CPU B的Cache就从Memory中加载最新的数据到Cache Line中,随后修改数据,然后改变Cache Line的状态为Modified

 

下图表示了当一个缓存行(Cache line)的调整的状态的时候,另外一个缓存行(Cache line)需要调整的状态。

状态

M

E

S

I

M

×

×

×

E

×

×

×

S

×

×

I

举例:
比如有某个变量a=1;
1.cache line处于M(修改)状态,其它cache对此变量都应是I(无效)状态
2.cache line处于S(共享)状态,其它cache对此变量可以是I(无效)状态,也可以是S(共享)状态

 

多核缓存示意图

比如有多个线程(此处3个),共同读取主存中的某个变量int z=1;

CPU缓存一致性协议(MESI)_数据_05

 

单核下的数据读取

CPU缓存一致性协议(MESI)_数据_06

1.CPU A发出了一条读取数据的指令,需要从主存中读取变量z。

2.首先从主存中将数据读取到BUS总线中。

3.再通过BUS总线读取到CPU A的缓存中。也就是Remote Read,此时cache line的状态需修改为E(独享)。

 

多核下的数据读取

CPU缓存一致性协议(MESI)_加载_07

1.CPU A发出了一条读取数据的指令,需要从主存中读取变量z。

2.首先从主存中将数据读取到BUS总线中。

3.再通过BUS总线读取到CPU A的缓存中。也就是Remote Read,此时cache line的状态需修改为E(独享)。

4.CPU B也发出了一条读取数据的指令,需要从主存中读取变量z。

5.CPU B尝试从主存中读取变量z,但被CPU A嗅探到了有内存地址的冲突。此时CPU A对数据做出状态更改,为S(共享),根据上面表格得到其它cache line的此变量需要是S或者I,于此当变量被读取到CPU B时也是S状态。

 

单核下的数据修改

1.CPU A发出了一条修改数据的指令,需要从主存中修改变量z。(一开始没其它cache读取,状态为I)

2.首先从主存中将数据读取到BUS总线中。

3.再通过BUS总线读取到CPU A的缓存中,进行Local write,此时cache line的状态需修改为M(修改)。

4.修改完了,再将数据回写到主存中。

CPU缓存一致性协议(MESI)_加载_08

 

多核下的数据修改及数据同步

修改:(承接上面多核读取结束后,CPU A对数据进行了修改)

1.CPU A进行Local write,修改变量z=2,此时要将其cache line的状态修改为M(修改),并通知有缓存了z变量的CPU,此处即CPU B。

2.CPU B需要将本地cache 中的z设置为I(无效)

3.CPU A对变量z进行赋值

CPU缓存一致性协议(MESI)_主存_09

同步:(涉及两种情况:其它CPU,如CPU B此时要读取z,或者CPU B此时要读取并修改z)

CPU B此时要读取z

1.CPU B发出读取z的指令(Remote read)

2.CPU A在总线上嗅探到这个读请求以后,先将Cache Line里修改过的数据回写(Write Back)到Memory中,然后在内存总线上放一份Cache Line的拷贝作为应答。

3.将自身的Cache Line的状态修改为Shared,由此产生的结果是CPU A与CPU B里对应的Cache Line的状态都为Shared。

CPU缓存一致性协议(MESI)_加载_10

CPU B此时要读取并修改z

1.CPU B发出读取z的指令(Remote Write)

2.CPU A在总线上嗅探到这个读请求以后,先阻止CPU B修改,然后将Cache Line里修改过的数据回写(Write Back)到Memory中,直接将自身cache line设置为I(无效)状态

3.CPU B再次获取修改请求,此时变量z在其它cache中没有缓存副本了,CPU B直接从主存中拿到最新的数据,进行修改操作,状态设置为M(修改)。

CPU缓存一致性协议(MESI)_缓存_11

 

MESI问题及优化

伪共享(False Sharing)

问题定义

说回CPU缓存,缓存行(cache line)是CPU缓存的基本单位,缓存行通常是 32/64 字节,前面说了局部性原理。

当我们访问一个数据时,获取一个值后,其相邻的值也被缓存到就近的缓存行中。比如访问一个long类型数组,当数组中的一个值被加载到缓存中,它会额外加载另外7个,以致你能非常快地遍历这个数组。因此可以非常快速的遍历在连续的内存块中分配的任意数据结构。

但是没有任何是完美的存在,比如:当有多个线程操作不同的成员变量,但正好这多个变量处于相同的缓存行。如图:

CPU缓存一致性协议(MESI)_高速缓存_12

注释:一个运行在处理器core1上的线程想要更新变量 X 的值,同时另外一个运行在处理器core2上的线程想要更新变量 Y 的值。
但是,这两个频繁改动的变量都处于同一条缓存行。两个线程就会轮番发送 RFO (Request For Owner) 消息,占得此缓存行的拥有权。
当 core1 取得了拥有权开始更新 X,则 core2 对应的缓存行需要设为 I 状态(失效态)。
当 core2 取得了拥有权开始更新 Y,则core1对应的缓存行需要设为 I 状态(失效态)。
轮番夺取拥有权不但带来大量的 RFO 消息,而且如果某个线程需要读此行数据时,L1 和 L2 缓存上都是失效数据,只有L3缓存上是同步好的数据。从前面的内容我们知道,读L3的数据会影响性能,更坏的情况是跨槽读取,L3 都出现缓存未命中,只能从主存上加载。

问题解决

1.padding
防止其他数据导致伪共享的问题常用增加padding,叫做缓存行填充的方式来解决,例如在前后加上无用的数据。

2.注解

在JDK1.8中,新增了一种注解@sun.misc.Contended,来使各个变量在Cache line中分隔开。

注意,jvm需要添加参数-XX:-RestrictContended才能开启此功能 。类前加上代表整个类的每个变量都会在单独的cache line中。属性前加代表该属性会在单独的cacheline中。

 

CPU切换状态堵塞

问题定义

众所周知,CPU的处理数据是非常快的,但MESI下,涉及到各个不同cache之间状态的转换通知(消息传递),这会耽误大量的时间(处理延迟)。而且CPU会一直等待消息传递和回应完成,其中的时间远大于一个指令的执行时间。

比如:CPU A需进行变量z的修改(Local Write),那必须通知其它CPU需要对缓存了z的缓存行置为I(无效)状态,并且要等所有CPU都响应确认。这等待期间会堵塞处理器,降低其性能等。

 

问题解决

存储缓存(Store Buffere)

为了解决等待太长时间避免资源浪费等,引入了store buffere。

处理器将想要写回到主存的数据写入到store buffere中,然后继续处理自己的事情。当发出去的所有设置无效状态的通知都响应了后,数据才会最终被同步到主存中去。

风险一:处理器会从store buffere中尝试加载数据,但其还没提交。称为store forwading,即当加载的时候,如果store buffere中有数据就进行返回;如果没有才能读取自己缓存里面的数据。

风险二:store buffere中的缓存什么时候能同步到主存中,没有任何保证。

 

内存屏障(Nenory Barriers)

  • 写屏障 Store Memory Barrier是一条告诉处理器在执行这之后的指令之前,应用所有已经在存储缓存(store buffer)中的保存的指令。
  • 读屏障Load Memory Barrier是一条告诉处理器在执行任何的加载前,先应用所有已经在失效队列中的失效操作的指令。

在相关代码前使用对应的读写屏障,保证数据的一致性。

我始终记住:青春是美丽的东西,而且对我来说,它永远是鼓舞的源泉。——(现代)巴金