缓存一致性协议硬核讲解

作者 | 牛在舒

面向护发编程程序员,呵护每一根秀发

从缓存一致性协议说起

相信大家都听说过 “缓存一致性协议”,那么它是为了解决哪些问题?在实际应用中的现状又是如何呢?

首先让我们认识一下 CPU 缓存。

一. CPU 高速缓存

CPU 高速缓存(Cache Memory)是位于 CPU 与内存之间的临时存储器,主要是为了解决 CPU 运行处理速度与内存读写速度不匹配的矛盾。

CPU 在执行指令时需要从内存获取指令和所需的数据,但是 CPU 的速度要远大于内存速度,所以 CPU 直接从内存中存取数据要等待一定时间周期,造成资源的浪费并且影响性能。

这时候就需要引入 CPU 缓存,现在常见的 CPU 的缓存结构为三级缓存,CPU缓存被分成了三个级别:L1,L2,L3。级别越小越接近CPU,所以速度也更快,同时也代表着容量越小。如下图:

CPU 往往需要重复处理相同的数据、指令,把这部分数据、指令放在 CPU 缓存中,CPU 就不需要从内存去读取数据、指令,从而减少了响应时间。这里也体现了局部性原理。

局部性原理:

  • 时间局部性:如果某个数据被访问,那么不久的将来他很可能被再次访问。

  • 空间局部性:如果某个数据被访问,那么与他相邻的数据很快也可能被访问。

CPU 缓存是由一组称之为缓存行(Cache Line)的固定大小的数据块组成的,缓存行是缓存中可以分配的最小存储单位,通常是64字节。

引入缓存之后,性能得到了提升,但是需要考虑一致性问题。

二. 一致性问题

引入了 CPU 高速缓存后,提升了效率,但是同时也会引发缓存与主存不一致的问题。对于单核 CPU 来说,处理比较简单,通常有以下两种方式:

  • 通写法(Write Through):每次 CPU 修改了缓存内容,立即更新到内存,也就意味着每次 CPU 写共享数据,都会导致总线事务。

  • 回写法(Write BACK):每次 CPU 修改了缓存数据,不会立即更新到内存,而是等到某个合适的时机才会更新到内存中去。

对于多核 CPU 来说,情况会更为复杂,如下图:

多核 CPU 存在多个一级缓存,如何保证这些缓存以及内存之间的一致性呢?

首先在通写法和回写法之外,又引入了两种操作:

  • 写失效:当一个 CPU 修改了数据,如果其他CPU有该数据,则通知其为无效。

  • 写更新:当一个 CPU 修改了数据,如果其他CPU有该数据,则通知其更新数据。

另外在 CPU 层面,提供了两种解决方案:

  • 总线锁:在多 CPU 情况下,某个 CPU 对共享变量操作时,在总线上发出一个 #LOCK 信号,总线把 CPU 和内存之间的通信锁住了,其他 CPU 不能操该内存地址的数据。

  • 缓存锁:降低了锁的粒度,基于缓存一致性协议来实现。

缓存一致性协议需要满足以下两种特性:

  • 写传播(Write propagation):一个处理器对于某个内存位置所做的写操作,对于其他处理器是可见的

  • 写串行化(Write Serialization):对同一内存单元的所有写操作都能串行化。即所有的处理器能以相同的次序看到这些写操作

对于写串行化:总线上任意时间只能出现一个 CPU 的写事件,多核并发的写事件会通过总线仲裁机制将其转换成串行化的的写事件序列。

对于写传播:大致可以分为以下两种方式:

  • 嗅探(Snooping ):广播机制,即要监听总线上的所有活动。

  • 基于目录(Directory-based):点对点,总线事件只会发给感兴趣的 CPU (借助 directory)。

我们经常提到的缓存一致性协议通常指的是:MESI 协议

三. MESI 协议

按照上文的概念,我们可以这样看待 MESI 协议:

  • 回写法

  • 写失效

  • 缓存锁

  • 写传播 + 写串行化

  • 嗅探机制

MESI 指的是缓存行的四种状态(Modified,Exclusive,Shared, Invalid),用 2 个 bit 表示。

3.1 四种状态
  • M: 被修改(Modified)

当前 CPU 缓存有最新数据, 其他 CPU 拥有失效数据,当前 CPU 数据与内存不一致,但以当前 CPU 数据为准。

  • E: 独享的(Exclusive)

只有当前 CPU 有数据,其他 CPU 没有该数据,当前 CPU 数据与内存数据一致。

  • S: 共享的(Shared)

当前 CPU 与其他 CPU 拥有相同数据,并与内存中数据一致。

  • I: 无效的(Invalid)

当前 CPU 数据失效,其他 CPU 数据可能有可能无,数据应从内存中读取,且当前 CPU 与 内存数据不一致。

3.2 四种操作
  • Local Read(LR):当前 CPU 读操作

  • Local Write(LW):当前 CPU 写操作

  • Remote Read(RR):其他 CPU 读操作

  • Remote Write(RW):其他 CPU 写操作

3. 3 状态转换

各个状态间的流转如图所示,下面我们来具体分析一下:

3.3.1 Modified
  • LR:当前 CPU 读操作,缓存中拥有最新数据,直接从缓存中读取,状态不变。

  • LW:当前 CPU 写操作,直接修改当前 CPU 缓存数据,修改后仍拥有最新数据,状态不变。

  • RR:其他 CPU 方式读操作,为了保证一致性,当前 CPU 将数据写回内存,随后 RR 使得其他 CPU 与当前 CPU 拥有相同数据,状态 改为 S。

  • RW:其他 CPU 写操作,当前 CPU 将数据写回内存,随后 RW 将内存数据修改,当前 CPU 缓存状态改为 I。

3.3.2 Exclusive
  • LR:当前 CPU 读操作,状态不变。

  • LW:当前 CPU 写操作,修改当前 CPU 缓存值,状态改为 M。

  • RR:其他 CPU 读操作,两个 CPU 和内存中数据一致,状态改为 S。

  • RW:其他 CPU 写操作,其他 CPU 数据为最新,当前 CPU 数据失效,状态改为 I。

3.3.3 Shared
  • LR:当前 CPU 读数据,状态不变

  • LW:当前 CPU 写操作,并不会将数据立即写回内存,为了保证一致性,将状态修改为 M。

  • RR:其他 CPU 读操作,因为多个 CPU 数据都与内存一致,状态不变。

  • RW:其他 CPU 写操作,其他 CPU 数据为最新,当前 CPU 数据失效,状态改为 I。

3.3.4 Invalid
  • LR:当前 CPU 读操作,当前 CPU 缓存不可用,需要读内存。

    • 其他 CPU 无数据,当前 CPU 独享数据,状态改为 E。

    • 其他 CPU 有数据且状态为 S 、E,当前CPU 与其他 CPU 以及内存数据一致,状态修改为 S。

    • 其他 CPU 有数据且状态为 M, 其他 CPU 先将数据写回内存,随后当前 CPU 读数据,与其他 CPU 以及内存数据一致,状态改为 S。

  • LW:当前 CPU 写操作,当前 CPU 缓存不可用,需要写内存。

    • 其他 CPU 无数据,只有当前 CPU 缓存有数据,且被修改与内存不一致,状态改为 M。

    • 其他 CPU 又数据且为 S、E,当前 CPU 缓存为最新且已修改,状态改为 M。

    • 其他 CPU 有数据且状态为 M, 其他 CPU 先将数据写回内存,随后当前 CPU 写数据,状态改为 M。

  • RR:其他 CPU 读操作,与当前 CPU 缓存无关,状态不变。

  • RW:其他 CPU 写操作,与当前 CPU 缓存无关,状态不变。

看到这里你可能会好奇,看起来这个协议没毛病啊?如果在 CPU 层面已经做好了约束,那么在 Java 内存模型里面为什么还有可见性和有序性问题呢?

四. MESI 协议的问题与优化

在 MESI 中,依赖总线嗅探机制,整个过程是串行的,可能会发生阻塞。

  1. 若 CPU0 发生 LW,首先需要发送一个 Invalidate 消息给到其他缓存了该数据的 CPU1。并且要等待 CPU1 的确认回执。CPU0 在这段时间内都会处于阻塞状态。

  2. 对于 CPU1 发生 RW,需要失效缓存。当其高速缓存压力很大时,要求实时的处理失效事件也存在一定的困难,会有一定的延迟。

4.1 MESI 协议优化

如果严格按照 MESI 协议,会有严重的性能问题。所以为了解决上面两个问题,引入了写缓冲区(Load Buffer)和失效队列(Invalid Queue)

4.1.1 写缓冲区(Load Buffer)

写缓冲区是属于每个 CPU 的,当使用了写缓冲区后,每当发生 LW,当前 CPU 不再阻塞地等待其他 CPU 的确认回执,而是直接将更新的值直接写入写缓冲区,然后继续执行后续指令。

在进行 LR 时,CPU 会先在写缓冲区中查询记录是否存在,如果存在则会从写缓冲区中直接获取,这一机制即是 Store Fowarding。

4.1.2 失效队列(Invalid Queue)

失效队列也是属于每个 CPU,使用失效队列后,发生 RW 对应的 CPU 缓存不再同步地失效缓存并发送确认回执,而是将失效消息放入失效队列,立即发送确认回执。

后续 CPU 会在空闲是对失效队列中的消息进行处理,将对应的 CPU 缓存失效。

4.1.3 优化后问题

引入 Load Buffer 后,即使读写指令本身是按照顺序执行的,但最终仍然可能会乱序执行。

例如:按顺序执行 A, B 两个写指令,A 写指令所在缓存行处于 S 状态,B 写指令所在缓存行处于E状态,那么 B 会比 A 先完成写入操作;又或者按顺序执行 C, D 两个读指令,C 读指令所在缓存行处于 I 状态,D 读指令所在缓存行处于 S 状态,那么 D 会比 C 先完成读取操作。

引入 Invalid Queue 后,可能会读取到过时的数据。

例如:CPU0 执行写指令,它向 CPU1 发出失效指令,然后 CPU1 立刻返回失效确认,但实际上并未真正执行失效操作。这时 CPU0 则更新了缓存行,造成了不同处理器直接的数据不一致。

4.2 CPU 内存屏障

MESI 原本是强一致性的,经过性能优化后,弱化成了最终一致性。在某些中间状态下,多个 CPU 之间的数据并不一致。同时也可能会发生乱序执行的情况,也就是重排序。

一般来说重排序分为以下三种:

  1. 编译器优化的重排序:编译器在不改变单线程程语义的前提下,可以重新安排语句的执行顺序

  2. 指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP), 将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  3. 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,使得加载和存储过程看上去是在乱序执行。

第一种属于编译器重排序,后两种属于处理器重排序

我们可以用内存屏障(Memory Barriers)去解决上面的一致性问题。

  • 写屏障(Store Memory Barrier):告诉 CPU 在执行屏障之后的指令前,将所有在存储缓存(store buffer)中的数据同步到内存。

  • 读屏障(Load Memory Barrier):告诉 CPU 在执行任何的加载前,先处理所有在失效队列(Invalid)中的消息。

但是 CPU 没办法自己判断应在何时何地添加内存屏障,它把决定权交给了软件应用层面。我们通常使用汇编指令 LOCK 做相应的处理。

LOCK 指令前缀有以下两大作用:

  1. 开启总线锁或者缓存锁(通过在总线上发送 #LOCK 信号。联系上文所说,这里的缓存锁一般就是缓存一致性协议实现的)

  2. 与被修饰的汇编指令一起提供内存屏障的效果

这样我们就在 CPU 层面,保证了多核 CPU 缓存的一致性。那么上层的软件系统又是怎么样处理的呢?我们以 JVM 为例进行介绍。

五. JMM

在 JVM 层面,定义了一种抽象的内存模型 (JMM)用来解决可见性和有序性问题。

5.1 Java 内存模型的抽象

JMM 属于语言级别的抽象内存模型,它定义了在共享内存中多线程读写的操作规范。JMM 将底层的问题抽象到 JVM 层面,基于 CPU 层面提供的内存屏障指令,以及限制编译器的重排序,来解决 CPU 多级缓存、处理器优化、指令重排导致的并发问题。

JMM 决定一个线程对共享变量的写入何时对另一个线程可见。如下图:

线程之间的共享变量存放在主内存中,每个线程都有自己的本地内存,本地内存中存放了共享变量的副本。需要注意的是:本地内存只是一个抽象概念,并不真实存在。它对应的是CPU 缓存,写缓冲区,寄存器等。

如果线程 A 和线程 B 之间需要通信:

  1. 首先线程 A 将本地内存 A 中更新过的共享变量刷新到主内存中去。

  2. 然后线程 B 再到主内存中去读取线程 A 之前更新过的共享变量。

JMM 通过控制主内存和每个线程间的本地内存的交互,来保证可见性。

5.2 JMM 层面内存屏障

上面提到 CPU 层面可以通过内存屏障指令来限制指令重排,其实 JSR 规范中也定义了 JMM 层面的内存屏障,共四种:

  • LoadLoad 屏障:操作序列 Load1,LoadLoad,Load2,在 Load2 及后续读取操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕。

  • LoadStore 屏障:操作序列 Load1,LoadStore,Store2,在 Store2 及其后续写入操作被刷出前,保证 Load1 要读取的数据被读取完毕。

  • StoreStore 屏障:操作序列 Store1,StoreStore,Store2,在 Store2 及后续写入操作执行前,保证 Store1 的写入操作对其他处理器可见。

  • StoreLoad 屏障:操作序列 Store1,StoreLoad,Load2,在 Load2 及后续的读取操作执行前,保证 Store1 的写入对其他处理器可见。

StoreLoad 屏障开销最大,并且兼具上面三种屏障的作用。这四种屏障是 Java 为了跨平台设计出来的规范,实际根据 CPU 的不同,可能会优化掉一些屏障。例如 X86 就只有 StoreLoad。

5.3 as-if-serial & happens-before

JMM 还提供了两组规则来约束重排序:as-if-serial 和 happens-before。它们保证了即使经过编译器和处理器优化后,程序执行的语义也不变。同时也更符合我们常规的认知。

5.3.1 as-if-serial

As-if-serial 的语义是指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。

5.3.2 happens-before

happens-before 指的是:前面的一个操作的结果对后续操作时可见的,这两个操作可以在同一线程内,也可在不同线程之间。

一般常用的有下面 几种规则:

  • 程序次序规则:在一个线程内,按照程序代码顺序,前面的操作 happends-before 后续的任意操作。

  • 管程锁定规则:对于同一锁的解锁 happens-before 后续对这个锁的加锁。

  • volatile 变量规则:对于一个 volatile 变量的写操作,happens-before 后续对这个 volatile 变量的读操作。

  • 线程启动规则:若线程 A 调用 B 的 start() 方法,那么 start() 操作 happens-before 线程 B 中的任意操作。

  • 线程终止规则:若在线程 A 中,调用线程 B 的 join() 方法并成功返回,那么线程 B 中的任意操作 happens-before 该 join() 操作的返回。

同时 happens-before 具有传递性,A happends-before B,B happens-before C,则 A happens-before C。

5.4 volatile

volatile大家应该都比较熟悉,简单来说,它有两方面的作用:

  1. 保证可见性

  2. 禁止重排序

那么它是如何做到的呢?其实归根到底就是Lock 指令。联系上文所提到的 Lock 指令的作用,相信大家应该就明白了。

到这里,一切好像都很顺利。但其实还有一个隐藏的性能问题,等待我们解决。那就是伪共享问题。

六. 伪共享问题
6.1 发生原因

当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,那么就会无意中影响到彼此的性能,这就是伪共享。

如上图所示, Core1 上线程想要更新变量 X,同时 Core2 上线程想要更新变量 Y ,而 X & Y 处在同一缓存行。每个线程需要竞争缓存行的所有权来更新变量。如果 Core1 获得所有权并成功更新了 X ,则 Core2 中对应的缓存行将会失效。如果 Core 获取所有权并成功更新 Y, 则 Core2 也要将对应的缓存行失效。这时读取就要去 L3 缓存,而 L3 相比于 L2 和 L1 性能将会差很多。如果 Core1 和 Core2 位于不同插槽,就要额外横跨插槽连接,问题可能更加严重。

6.2 解决方法

解决方法其实很简单,就让每个变量独占一个缓存行, 不共享缓存行就行了。

6.2.1 方案1:手动填充

例如 :Disruptor 中 Sequence 对象中 value 的处理

关于 Disruptor: 一种高性能的有界内存队列,高性能原因如下:

  1. 内存分配更加合理,使用 RingBuffer 数据结构,数组元素在初始化时一次性全部创建,提升缓存命中率;对象循环利用,避免频繁 GC.

  2. 能够避免伪共享,提升缓存利用率。

  3. 采用无锁算法,避免频繁加锁、解锁的性能消耗

  4. 采用批量消费,消费者可以无锁方式消费多个消息

class LhsPadding
{
    protected long p1, p2, p3, p4, p5, p6, p7;
}

class Value extends LhsPadding
{
    protected volatile long value;
}

class RhsPadding extends Value
{
    protected long p9, p10, p11, p12, p13, p14, p15;
}

public class Sequence extends RhsPadding {}

按照一行缓存 64 字节计算,前后填充 56 字节(7 个 long),则中间的 value 必能独占一行 Cache line,

6.2.3 方案2 :@sun.misc.Contended

在 java8 中,新增了注解 @sun.misc.Contended ,用于使变量处在不同的缓存行中(也是通过在变量前后填充字节实现)。需要注意的是 JVM 需要增加参数-XX:-RestrictContended 才能开启此功能.

从 Java9 之后,引入了模块系统,需要使用 @jdk.internal.vm.annotation.Contended

例如:ConcurrentHashMap 中的 CounterCell

ConcurrentHashMap是采用CounterCell数组来记录元素个数

   @jdk.internal.vm.annotation.Contended static final class CounterCell {
        volatile long value;
        CounterCell(long x) { value = x; }
    }

我们也可以使用 @sun.misc.Contended 使得多个变量处在同一缓存行。例如在 Thread 类中:

   @jdk.internal.vm.annotation.Contended("tlr")
    long threadLocalRandomSeed;

    @jdk.internal.vm.annotation.Contended("tlr")
    int threadLocalRandomProbe;

    @jdk.internal.vm.annotation.Contended("tlr")
    int threadLocalRandomSecondarySeed;

在我们的业务开发中,其实没必要过于考虑缓存行伪共享对于性能的影响。

七. 写在最后

我们从缓存一致性协议说起,下到 CPU 多级缓存,上到 JMM 内存模型,整体上做了一个简单的介绍,希望能给大家一些帮助。因为笔者个人能力有限,文章中堆砌了大量的概念定义,以及引用了许多其他文章的内容,望大家见谅。

参考

  1. Memory part 2: CPU caches:https://lwn.net/Articles/252125/

  2. CPU 多级缓存:https://blinkfox.github.io/2018/11/18/ruan-jian-gong-ju/cpu-duo-ji-huan-cun/

  3. 深入理解 Java 内存模型:https://www.infoq.cn/minibook/java_memory_model

  4. Java 并发编程实战:https://time.geekbang.org/column/article/84017

  5. False Sharing:https://mechanical-sympathy.blogspot.com/2011/07/false-sharing.html

全文完


以下文章您可能也会感兴趣:

我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值