Cache一致性协议与伪共享问题
Cache一致性协议
在说伪共享问题之前,有必要聊一聊什么是Cache一致性协议
局部性原理
时间局部性:如果一个信息项正在被访问,那么在近期它很可能还会被再次访问
比如循环、方法的反复调用等
空间局部性:如果一个存储器的位置被引用,那么将来他附近的位置也会被引用
比如顺序结构、数组
Cache的作用
CPU在摩尔定律的指导下以每18个月翻一番的速度在发展,然而内存和硬盘的发展速度远远不及CPU。为了解决这个问题,CPU厂商在CPU中内置了少量的高速缓存Cache,以解决访存速度和CPU运算速度之间不匹配的问题
带Cache的CPU访存过程
CPU和Cache交换数据以字为单位。Cache与主存以块为单位,一个缓存行(Cache Line)对应一个主存块
读:
- Cache命中,则直接从Cache中读取数据
- Cache不命中,则访问主存,并将一个主存块调入Cache中,存入为一个缓存行。这个过程中可能由于Cache满而发生替换,替换算法包括RAND、FIFO、LRU、LFU
写:
- Cache命中时
- 写回法(write back):CPU只将数据写入Cache,只有当数据调出Cache时,才写入主存
- 写穿法(write through):CPU同时将数据写入Cache和主存
- Cache不命中时
- 写分配法:从主存中将数据块调入Cache,并修改Cache,和写回法配合使用
- 非写分配法:只写入主存,不调入Cache,和写穿法配合使用
Cache和主存的映射方式(三种):直接映射、全相联映射、组相联映射
如果是单CPU结构,这么执行没有其他问题。但是现代系统往往包含多个CPU,每个CPU都有各自的Cache。多核CPU的情况下有多个一级缓存,如何保证缓存内部数据的一致性,不让系统数据混乱。这里就引出了Cache一致性协议——MESI。注意,这并不是唯一的缓存一致性协议,还有其他协议如MOSEI(相对于MESI多引入了一个Owned状态,并重新定义了S状态),这里不多介绍
MESI协议详解
MESI(Modified Exclusive Shared Or Invalid),也称伊利诺斯协议,是一种广泛使用的、支持写回策略的缓存一致性协议。MESI协议其实就是使用4种状态来标记各个缓存行(Cache Line)的状态,而这些状态英文首字母缩写就构成了“MESI”
MESI协议中的各种状态
每个缓存行都使用一个状态来标记,该状态总共有4种,使用2bit进行存储:
- M(Modified):相应的数据只被缓存在该CPU的Cache中,但数据是被修改过的(脏数据),即与主存中的数据不一致。该缓存行中的内存需要在未来的某个时间点,但必须是其它CPU读取主存中相应内存之前,将数据写回主存
- E(Exclusive):相应的数据只被缓存在该CPU的缓存中,数据是未被修改过的,与主存中的数据一致
- S(Shared):相应的数据被多个CPU缓存,且各个CPU的Cache中的数据和主存都是一致的
- I(Invalid):该缓存行中的数据是无效的,因为有其他CPU修改了数据
总线嗅探机制(监听)
每个CPU都可以感知其他CPU的行为,比如读、写某个缓存行,这就是嗅探机制,也称监听。所有的缓存行(除了Invalid状态)都需要监听自己和其他CPU对相应的缓存行的读写操作,也称触发事件,从而根据触发事件和自身状态,进行状态的转换
各种触发事件
触发事件 | 描述 |
---|---|
本地读取(Local Read) | 本CPU读取本Cache的数据 |
本地写入(Local Write) | 本CPU向本Cache写入数据 |
远端读取(Remote Read) | 其他CPU读取它们各自Cache的数据 |
远端写入(Remote Write) | 其他CPU向它们各自Cache写入数据 |
MESI中各个状态之间的转换
下图描述了当前缓存行在不同触发事件下的状态切换:
下表是对上图的一个详细解释:
举例
假设CPU0、CPU1、CPU2、CPU3中有一个缓存行(包含变量x)都是S状态
此时CPU1要对变量x进行写操作,这时候通过总线嗅探机制,CPU0、CPU2、CPU3中的缓存行会置为I状态(无效),然后给CPU1发响应,收到全部响应后CPU1会完成对变量x的写操作,并更新CPU1内的缓存行为M状态,但不会将数据x同步到主存中
接着CPU0想要对变量x执行读操作,却发现本地缓存行是I状态,就会触发CPU1去把缓存行写回到主存中,然后CPU0再去主存中同步最新的值
其他一些细节
1、写缓冲
前面的描述隐藏了一些细节,比如实际CPU1在执行写操作,更新缓存行的时候,其实并不会等待其他CPU的状态都置为I状态,才去做些操作,这是一个同步行为,效率很低。当前的CPU都引入了写缓存器技术,也就是在CPU和cache之间又加了一层buffer,在CPU执行写操作时直接向写缓冲写入数据,然后就忙其他事去了,等其他CPU都置为I之后,CPU1才把buffer中的数据写入到缓存行中
2、多级缓存
现代系统都会采用多级缓存架构,L1-L3级缓存,其中L3缓存是所有CPU共享的一个缓存,但MESI的描述中并没有涉及L3缓存。其实上文提到的所有跟“主存”交换数据的地方,在L3缓存存在的情况下,都应该替换为L3缓存。比如我上一节举的例子中,CPU0中某缓存行是I,CPU1 中是M。当CPU0想到执行local read操作时,就会触发CPU1中的缓存写入到主存中,然后CPU0从主存中取最新的缓存行。其实这里的描述是不准确的,因为由于L3缓存的存在,这里其实是直接从L3缓存读取缓存行,而不直接访问主存。个人认为是如果在描述MESI的状态流转时,如果引入L3缓存,会使得描述过于复杂,因此一般的描述都会刻意忽略L3缓存
**
版权:本文版权归作者和博客园共有
转载:欢迎转载,但未经作者同意,必须保留此段声明;必须在文章中给出原文连接;否则必究法律责任
**
伪共享
由于内存和Cache之间的交换单位是内存块/缓存行,因此如果访问一个变量,会将一整个内存块读入一个缓存行。但是如果多个线程访问的变量不相同,且这些变量在内存中的位置临近,那么很可能在同一个缓存行中。在Cache一致性协议(如MESI协议)的约束下,多个线程(CPU)在并行读写相对应的缓存行会有限制,因此其他线程不得不去访问低级别的Cache甚至是主存,这会导致cache没有起到真正的作用,程序性能下降
伪共享示例
如图,线程1访问变量x,而线程2访问变量y,而这两个变量在内存中的位置临近(在同一个内存块中),虽然线程都将该内存块读入到各自的工作内存(Cache)中,但是在Cache一致性协议的约束下,同一时间两个线程很难自由地读写相同位置的缓存行,那么可能就会让其中一个线程去低级别的内存中读写数据,性能因此降低,这就是伪共享
一般地址连续的多个变量更可能被放在同一个缓存行中,例如创建数组时,数组中的多个元素更可能被放入同一个缓存行中
如何避免伪共享问题
JDK8之前
JDK8之前,使用字节填充的方式,即创建一个变量时,使用填充字段填充该变量所在的缓存行,从而避免多个变量被放入同一个缓存行中,如下:
public final static class FilledLong {
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6;
}
一般来说,缓存行为64 Byte,而经过填充的FilledLong
对象有7*8 Byte=56 Byte,而FilledLong
对象的对象头也有8 Byte,正好填满一个缓存行
JDK8及之后
JDK8提供了一个注解——sun.misc.Contended
,用于解决伪共享问题,上述代码可以修改为如下:
@sun.misc.Contended
public final static class FilledLong {
public volatile long value = 0L;
}
在Thread
类中,也有这样的字段,如下:
@sun.misc.Contended("tlr")
long threadLocalRandomSeed;
/** Probe hash value; nonzero if threadLocalRandomSeed initialized */
@sun.misc.Contended("tlr")
int threadLocalRandomProbe;
/** Secondary seed isolated from public ThreadLocalRandom sequence */
@sun.misc.Contended("tlr")
int threadLocalRandomSecondarySeed;
但是,@Contended
注解只用于Java核心类,而用户类路径下的类使用该注解,需要添加JVM参数-XX:-RestrictContended
。填充宽度默认为128 Byte,也可以自定义宽度,通过JVM参数-XX:ContendedPaddingWidth
来设定