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状态)、MESIF(配合NUMA架构)等,这里不多介绍

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再去主存中同步最新的值

MESI协议解决了缓存一致性的问题,但其中有一个问题,那就是需要在等待其他处理器全部回复后才能进行下一步操作,这种等待明显是不能接受的,下面就继续来看看如何解决处理器等待的问题

写缓冲和无效化队列

1、写缓冲(Store Buffer)
实际CPU1在执行写操作要更新缓存行时,其实并不会乖乖地等待其他CPU的缓存行状态都置为I状态,才去执行写操作,这样效率很低。实际做法是:每个CPU都引入一个写缓存器,相当于在CPU和Cache之间又加了一层buffer,在CPU执行写操作时直接写入写缓冲,然后去忙其他事,等其他CPU的缓存行都置为I后,CPU1才把buffer中的数据写入到Cache中

2、无效化队列(Invalidate Queue)
引入写缓冲后,CPU1就可以不用等待其他CPU中对应的缓存行失效而去忙别的。不过其他CPU也不傻,实际上他们也不会真的把缓存行置为I后,才给CPU1发响应。他们会写入一个无效化队列,还没把缓存置为I状态就发送响应了
之后CPU会异步扫描无效化队列,将缓存行置为I状态。和写缓冲不同的是,CPU1之后读变量x时,会先查写缓冲,再查Cache。而CPU0要读变量x时,不会先去检查无效化队列,所以存在脏读的可能

总之,写缓冲器解决了写数据时要等待其他处理器响应的问题,无效化队列解决了删除数据的等待问题。不管是写缓冲器还是无效化队列,其实都是为了减少处理器的等待时间,采用了空间换时间的方式来实现命令的异步处理

不过既然是异步处理,就又会引发新的问题——内存重排序内存可见性问题,这里不过多赘述,请参考网上其他文章,之后博主也会跟进

看懂这篇,才能说了解并发底层技术

多级缓存架构对MESI的影响

现代系统都会采用多级缓存架构,L1-L3级缓存,其中L3缓存是所有CPU共享的一个缓存,但MESI的描述中并没有涉及L3缓存。其实上文提到的所有跟“主存”交换数据的地方,在L3缓存存在的情况下,都应该替换为L3缓存。比如我上一节举的例子中,CPU0中某缓存行是I,CPU1 中是M。当CPU0想到执行local read操作时,就会触发CPU1中的缓存写入到主存中,然后CPU0从主存中取最新的缓存行。其实这里的描述是不准确的,因为由于L3缓存的存在,这里其实是直接从L3缓存读取缓存行,而不直接访问主存

个人认为是如果在描述MESI的状态流转时,如果引入L3缓存,会使得描述过于复杂,因此一般的描述都会刻意忽略L3缓存

作者:酒冽        出处:https://www.cnblogs.com/frankiedyz/p/15786362.html
版权:本文版权归作者和博客园共有
转载:欢迎转载,但未经作者同意,必须保留此段声明;必须在文章中给出原文连接;否则必究法律责任

伪共享

由于内存和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来设定

posted @ 2022-01-10 23:11  酒冽  阅读(768)  评论(2编辑  收藏  举报