1 cpu高速缓存
CPU 往往需要重复处理相同的数据、指令,把这部分数据、指令放在 CPU 缓存中,CPU 就不需要从内存去读取数据、指令,从而减少了响应时间。这里也体现了局部性原理。
CPU 缓存是由一组称之为缓存行(Cache Line)的固定大小的数据块组成的,缓存行是缓存中可以分配的最小存储单位,通常是64字节。
引入缓存之后,性能得到了提升,但是需要考虑一致性问题。
2 一致性问题
对于单核 CPU 来说,处理比较简单,通常有以下两种方式:
-
通写法(Write Through):每次 CPU 修改了缓存内容,立即更新到内存,也就意味着每次 CPU 写共享数据,都会导致总线事务。
-
回写法(Write BACK):每次 CPU 修改了缓存数据,不会立即更新到内存,而是等到某个合适的时机才会更新到内存中去。
多核 CPU 存在多个一级缓存,首先在通写法和回写法之外,又引入了两种操作:
-
写失效:当一个 CPU 修改了数据,如果其他CPU有该数据,则通知其为无效。
-
写更新:当一个 CPU 修改了数据,如果其他CPU有该数据,则通知其更新数据。
另外在 CPU 层面,提供了两种解决方案:
-
总线锁:在多 CPU 情况下,某个 CPU 对共享变量操作时,在总线上发出一个 #LOCK 信号,总线把 CPU 和内存之间的通信锁住了,其他 CPU 不能操该内存地址的数据。
-
缓存锁:降低了锁的粒度,基于缓存一致性协议来实现。
缓存一致性协议需要满足以下两种特性:
-
写传播(Write propagation):一个处理器对于某个内存位置所做的写操作,对于其他处理器是可见的
-
写串行化(Write Serialization):对同一内存单元的所有写操作都能串行化。即所有的处理器能以相同的次序看到这些写操作
对于写串行化:总线上任意时间只能出现一个 CPU 的写事件,多核并发的写事件会通过总线仲裁机制将其转换成串行化的的写事件序列。
对于写传播:大致可以分为以下两种方式:
- 嗅探(Snooping ):广播机制,即要监听总线上的所有活动。
- 基于目录(Directory-based):点对点,总线事件只会发给感兴趣的 CPU (借助 directory)。
我们经常提到的缓存一致性协议通常指的是:MESI 协议
- 回写法
- 写失效
- 缓存锁
- 写传播 + 写串行化
- 嗅探机制
3 MESI协议
3.1 四种状态
- M: 被修改(Modified)
当前 CPU 缓存有最新数据, 其他 CPU 拥有失效数据,当前 CPU 数据与内存不一致,但以当前 CPU 数据为准。
- E: 独享的(Exclusive)
只有当前 CPU 有数据,其他 CPU 没有该数据,当前 CPU 数据与内存数据一致。
- S: 共享的(Shared)
当前 CPU 与其他 CPU 拥有相同数据,并与内存中数据一致。
- I: 无效的(Invalid)
当前 CPU 数据失效,其他 CPU 数据可能有可能无,数据应从内存中读取,且当前 CPU 与 内存数据不一致。
3.2 四种操作
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 缓存无关,状态不变。
4 MESI 协议的问题与优化
在 MESI 中,依赖总线嗅探机制,整个过程是串行的,可能会发生阻塞。
若 CPU0 发生 LW,首先需要发送一个 Invalidate 消息给到其他缓存了该数据的 CPU1。并且要等待 CPU1 的确认回执。CPU0 在这段时间内都会处于阻塞状态。
对于 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,font>使用失效队列后,发生 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 则更新了缓存行,造成了不同处理器直接的数据不一致。
x = 2;
b = x + 1:
判断b == 3
复制代码
正确情况下的执行方式
异常–单核
storeBuffer来不及确认,已经执行到下一步了
异常–多核
A刚刚写入x=2到StoreBuffer,还没传递下去,B已经开始操作x了
4.2 CPU 内存屏障
MESI 原本是强一致性的,经过性能优化后,弱化成了最终一致性。在某些中间状态下,多个 CPU 之间的数据并不一致。同时也可能会发生乱序执行的情况,也就是重排序。
一般来说重排序分为以下三种:
编译器优化的重排序:编译器在不改变单线程程语义的前提下,可以重新安排语句的执行顺序
指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP), 将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统的重排序:由于处理器使用缓存和读/写缓冲区,使得加载和存储过程看上去是在乱序执行。
第一种属于编译器重排序,后两种属于处理器重排序
我们可以用内存屏障(Memory Barriers)去解决上面的一致性问题。
-
写屏障(Store Memory Barrier):强制将Store Buffer中的内容写入到缓存中或者将该指令之后的写操作写入store buffer直到之前的内容被刷入到缓存中,也被称之为smp_wmb======>出问题的地方在于 store buffer
-
读屏障(Load Memory Barrier):告诉 CPU 在执行任何的加载前,先处理所有在失效队列(Invalid)中的消息。====>读出问题的地方在于 invalid queue
但是 CPU 没办法自己判断应在何时何地添加内存屏障,它把决定权交给了软件应用层面。我们通常使用汇编指令 LOCK 做相应的处理。
LOCK 指令前缀有以下两大作用:
-
开启总线锁或者缓存锁(通过在总线上发送 #LOCK 信号。联系上文所说,这里的缓存锁一般就是缓存一致性协议实现的)
-
与被修饰的汇编指令一起提供内存屏障的效果
这样我们就在 CPU 层面,保证了多核 CPU 缓存的一致性。那么上层的软件系统又是怎么样处理的呢?我们以 JVM 为例进行介绍。
5 JMM
在 JVM 层面,定义了一种抽象的内存模型 (JMM)用来解决可见性和有序性问题。
5.1 Java 内存模型的抽象
JMM 属于语言级别的抽象内存模型,它定义了在共享内存中多线程读写的操作规范。JMM 将底层的问题抽象到 JVM 层面,基于 CPU 层面提供的内存屏障指令,以及限制编译器的重排序,来解决 CPU 多级缓存、处理器优化、指令重排导致的并发问题。
JMM 决定一个线程对共享变量的写入何时对另一个线程可见。如下图:
线程之间的共享变量存放在主内存中,每个线程都有自己的本地内存,本地内存中存放了共享变量的副本。需要注意的是:本地内存只是一个抽象概念,并不真实存在。它对应的是CPU 缓存,写缓冲区,寄存器等。
如果线程 A 和线程 B 之间需要通信:
-
首先线程 A 将本地内存 A 中更新过的共享变量刷新到主内存中去。
-
然后线程 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 与volatile的联系
java中对于内存屏障的使用最多的就是volatile关键字,那么到底是如何操作的呢:
在每个volatile写操作的前面插入一个StoreStore屏障,保证volatile写操作前面的Store Buffer队列中的操作都已经刷新到缓存中,防止前面的写操作与volatile写操作发生指令重排。
在每个volatile写操作的后面插入一个StoreLoad屏障,保证后面的其他写/读操作前面的Store Buffer队列中的操作都已经刷新到缓存中(也就是volatile写操作),防止后面的其他写/读操作与volatile写操作发生指令重排。
在每个volatile读操作的后面插入一个LoadLoad屏障,保证后面的其他读操作的无效化队列已经将volatile无效刷新到缓存中,防止后面的读操作与volatile读操作发生指令重排。
在每个volatile读操作的后面插入一个LoadStore屏障,保证后面的其他写操作的无效化队列已经将volatile无效刷新到缓存中,防止后面的写操作与volatile读操作发生指令重排。