CPU缓存的由来
我们知道CPU的处理能力要远比内存强,主内存执行一次内存读、写操作的时间可能足够处理器执行上百条的指令。为了弥补处理器与内存处理能力之间的鸿沟,在内存和处理器之间引入了高速缓存(Cache)。高速缓存是一种存取速率远比主内存大而容量远比主内存小的存储部件,每个处理器都有其高速缓存。如下图所示
CPU的读(load)实质上就是从缓存中读取数据到寄存器(register)里,在多级缓存的架构中,如果缓存中找不到数据(cache miss),就会层层读取二级缓存三级缓存,一旦所有的缓存里都找不到对应的数据,就要去内存里寻址了。寻址到的数据首先放到寄存器里,其副本会驻留到CPU的缓存中。
CPU的写(store)也是针对缓存作写入。并不会直接和内存打交道,而是通过某种机制实现数据从缓存到内存的写回(write back)。
CPU缓存的概念
CPU缓存是位于CPU与内存之间的临时数据交换器,它的容量比内存小的多但是交换速度却比内存要快得多。CPU缓存一般直接跟CPU芯片集成或位于主板总线互连的独立芯片上。
为了简化与内存之间的通信,高速缓存控制器是针对数据块,而不是字节进行操作的。高速缓存其实就是一组称之为缓存行(Cache Line)的固定大小的数据块组成的,典型的一行是64字节。
CPU缓存的意义
CPU往往需要重复处理相同的数据、重复执行相同的指令,如果这部分数据、指令CPU能在CPU缓存中找到,CPU就不需要从内存或硬盘中再读取数据、指令,从而减少了整机的响应时间。所以,缓存的意义满足以下两种局部性原理:
- 时间局部性(Temporal Locality):如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。
- 空间局部性(Spatial Locality):如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。
缓存一致性协议-MESI协议
由于现在一般是多核处理器,每个处理器都有自己的高速缓存,那么会导致一些问题:
当某一个数据在多个处于“运行”状态的线程中进行读写共享时(例如ThreadA、ThreadB和ThreadC),第一个问题是多个线程可能在多个独立的CPU内核中“同时”修改数据A,导致系统不知应该以哪个数据为准;第二个问题是由于ThreadA进行数据A的修改后没有即时写会内存ThreadB和ThreadC也没有即时拿到新的数据A,导致ThreadB和ThreadC对于修改后的数据不可见。这就是缓存一致性问题。
为了解决这个问题,处理器之间需要一种通信机制----缓存一致性协议。
MESI(Modified-Exclusive-Shared-Invalid)协议是一种广为使用的缓存一致性协议。MESI协议对内存数据访问的控制类似于读写锁,它使得针对同一地址的读内存操作是并发的,而针对同一地址的写内存操作是独占的。
之所以叫 MESI,是因为这套方案把一个缓存行(cache line)区分出四种不同的状态标记,他们分别是 Modified、Exclusive、Shared 和 Invalid。这四种状态分别具备一定的意义:
状态 | 描述 | 监听任务 | 状态转换 |
---|---|---|---|
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无效。 | 无 | 无 |
这些状态本身是静态的,那么动态来看,又是如何产生状态变化的呢?
首先不同CPU之间也是需要沟通的,这里的沟通是通过在消息总线上传递message实现的。这些在总线上传递的消息有如下几种:
- Read :带上数据的物理内存地址发起的读请求消息;
- Read Response:Read 请求的响应信息,内部包含了读请求指向的数据;
- Invalidate:该消息包含数据的内存物理地址,意思是要让其他如果持有该数据缓存行的 CPU 直接失效对应的缓存行;
- Invalidate Acknowledge:CPU 对Invalidate 消息的响应,目的是告知发起 Invalidate 消息的CPU,这边已经失效了这个缓存行啦;
- Read Invalidate:这个消息其实是 Read 和 Invalidate 的组合消息,与之对应的响应自然就是一个Read Response 和 一系列的 Invalidate Acknowledge;
- Writeback:该消息包含一个物理内存地址和数据内容,目的是把这块数据通过总线写回内存里。
举个例子
现在有 cpu0 cpu1 变量a
现在cpu0对a赋值 a=1
假如变量a不在cpu0 缓存中,则需要发送 Read Invalidate 信号,再等待此信号返回Read Response和Invalidate Acknowledge,之后再写入量到缓存中。
假如变量a在cpu0 缓存中,如果该量的状态是 Modified 则直接更改发送Writeback 最后修改成Exclusive。而如果是 Shared 则需要发送 Invalidate 消息让其它 CPU 感知到这一更改后再更改。
- 一般情况下,CPU 在对某个缓存行修改之前务必得让其他 CPU 持有的相同数据缓存行失效,这是基于 Invalidate Acknowledge 消息反馈来判断的;
- 缓存行为 M 状态,意味着该缓存行指向的物理内存里的数据,一定不是最新;
- 在修改变量之前,如果CPU持有该变量的缓存,且为 E 状态,直接修改;若状态为 S ,需要在总线上广播 Invalidate;若CPU不持有该缓存行,则需要广播 Read Invalidate。
Store Buffers
这个极简的 CPU 缓存架构存在一定的问题,当相当一部分 CPU 持有相同的数据时(S 状态),如果其中有一个 CPU 要对其进行修改,则需要等待其他 CPU 将其共同持有的数据失效,那么这里就会有空等期(stall),这对于频率很高的CPU来说,简直不能接受!
这里引入了Store buffers
这是一个 CPU 在真正写入缓存之前的的缓冲区,缓冲区作用在于 CPU 无需等待其他 CPU 的反馈,把要写入的数据先丢到 Store Buffer 中,自己可以去处理别的事情,避免了CPU的傻等。
Store Forwarding
引入store buffer之后又带了新的问题,单个 CPU 在顺序执行指令的过程中,有可能出现,前面的已经执行写入变更,但对后面的代码逻辑不可见
举个例子
假设 a , b 初始值为0:
a=1
b=a+1
assert(a==2)
cpu对a赋值为1,此时a变量进入到storebuffer,缓存中的a还是等于0,此时执行b=a+1得到的结果是b=1,assert不通过。
解决方案就是采用Store Forwarding
对于同一个 CPU 而言,在读取 a 变量的时候,如若发现 Store Buffer 中有尚未写入到缓存的数据 a,则直接从 Store Buffer 中读取。这就保证了,逻辑上代码执行顺序,也保证了可见性
Memory Barriers
通过 Store Forwarding 解决了单个 CPU 执行顺序性和内存可见性问题,但是在全局多 CPU 的环境下,这种内存可见性恐怕就很难保证了。
void foo(void)
{
a = 1;
b = 1;
}
void bar(void)
{
while (b == 0) continue;
assert(a == 1);
}
假设上面的 foo 方法被 CPU 0 执行,bar 方法被 CPU 1 执行,也就是我们常说的多线程环境。试想,即便在多线程环境下,foo 和 bar 如若严格按照理想的顺序执行,是无论如何都不会出现 assert failed 的情况的。但往往事与愿违,这种看似很诡异的且有一定几率发生的 assert failed ,结合上面所说的 Store Buffer 就一点都不难理解了。
我们来还原 assert failed 的整个过程,假设 a,b 初始值为 0 ,a 被 CPU0 和 CPU1 共同持有,b 被 CPU0 独占;
CPU0 处理 a=1 之前发送 Invalidate 消息给 CPU1 ,并将其放入 Store Buffer ,尚未及时刷入缓存;
CPU 0 转而处理 b=1 ,此时 b=1 直接被刷入缓存;
CPU 1 发出 Read 消息读取 b 的值,发现 b 为 1 ,跳出 while 语句;
CPU 1 发出 Read 消息读取 a 的值,发现 a 却为旧值 0,assert failed。
在日常开发过程中也是完全有可能遇到上面的情况,由于 a 的变更对 CPU1 不可见,虽然执行指令的时序没有真正被打乱,但对于 CPU1 来说,这造成了 b=1 先于 a=1 执行的假象,这种看是乱序的问题,通常称为 “重排序”。当然上面所说的情况,只是指令重排序的一种可能。
解决办法就是 Memory Barrier(内存屏障)。借助内存屏障可以很好地保证了顺序一致性。
void foo(void)
{
a = 1;
smp_mb();
b = 1;
}
void bar(void)
{
while (b == 0) continue;
assert(a == 1);
}
这个屏障可以理解为两条指令之间的栅栏(fence),比如在上面的 foo 方法中,a 的赋值和 b 的赋值之间势必要执行这个栅栏。这个栅栏有什么用呢?
smp_mb 首先会使得 CPU 在后续变量变更写入之前,把 Store Buffer 的变更写入 flush 到缓存;CPU 要么就等待 flush 完成后写入,要么就把后续的写入变更放到 Store Buffer 中,直到 Store Buffer 数据顺序刷到缓存。
CPU 的设计者兼顾性能和指令重排序之间做了权衡,认为其实在大多数场景下,多线程环境下的指令重排序和可见性问题是可以接受的,并且这有助于 CPU 发挥出该有的性能。如果真的有特殊需求,我们可以借助内存屏障来解决,虽然有一定的代码侵入性,但是这样的 tradeoff 是相当划算的。
Invalidate Queues
然而从目前设计看,还依然有问题。试想这么一个场景,CPU 写入一大串数据到 Store Buffer,而这些缓存行均被其他 CPU 持有,那么此时这个 CPU 需要等待一系列的 Invalidate Acknowledge 反馈后才能将这批数据 flush 到缓存行。
这里存在的问题是,Store Buffer 本身很小,如果写入变更指向的变量在CPU 本地缓存中均是 cache miss 的情况下,变更数量超过了 Store Buffer 能承载的容量,CPU 依然需要等待 Store Buffer 排空后才能继续处理。尤其是执行 Memory Barrier 以后,无论本地缓存是否 cache miss,只要 Store Buffer 还有数据,所有的写入变更都要进入 Store Buffer。这就导致 CPU 依然存在的空等(stall)现象。
CPU 设计者的思路是,尽可能减少 invalidate ack 的时延,以减少CPU的无谓等待。目前的方案是,CPU 一旦收到 Invalidate 消息,先是会去缓存中标记该缓存状态为 I ,标记完毕后发送 invalidate ack 到消息总线。那如果 CPU 接收到 invalidate 消息,立马反馈 invalidate ack,而cache line 此时也并非强制要求马上失效,只要确保最终会失效即可,这样的思路是否可以呢?
可以,基于这个思路, Invalidate Queues 应运而生。
每个 CPU 都有一个 Invalidate Queue,用以把需要失效的数据物理地址存储起来,根据这个物理地址,我们可以对缓存行的失效行为 “延后执行” 。这样做的好处上面也说过,又一次释放了 CPU 的发挥空间,但依然有额外的副作用。继续来看上面的例子:
void foo(void)
{
a = 1;
smp_mb();
b = 1;
}
void bar(void)
{
while (b == 0) continue;
assert(a == 1);
}
引入 Invalidate Queue 后,assert failed 死灰复燃。我们来重现下:
假设 a,b 初始值为0,CPU0 执行 foo 方法,CPU1 执行 bar 方法,b 被CPU0 独占,a 则被 CPU0 和 CPU1 共同持有。
- CPU0 执行 a=1,由于缓存行状态为 S ,需要发送 Invalidate 消息到总线;
- CPU1 接收到 Invalidate 消息,将数据内存地址放入 Invalidate Queue 后立马反馈 Invalidate Acknowledge;
- CPU 0 收到反馈后,把 a=1 从 Store Buffer 刷到缓存后,执行 b=1,b 的新值 1 直接被写入到了 CPU 0 的缓存中;
- CPU1 执行 while 语句,通过发送 Read 指令查询 b 的值,此时 b 为 1,跳出 while;
- CPU1 执行 assert(a==1) ,a 的 invalidate 信息还在 Invalidate Queue 中,CPU1 缓存中的 a 仍然是旧值 0,assert failed。
表象上看依然是指令执行顺序被打乱了,这似乎用 Memory Barrier 也有问题呀,解决方案就是要使用更多的 Memory Barrier 。
void foo(void)
{
a = 1;
smp_mb();
b = 1;
}
void bar(void)
{
while (b == 0) continue;
smp_mb();
assert(a == 1);
}
不过这里的 smp_mb 有更丰富的语义,除了与 Store Buffer 的交互外,一旦执行到 smp_mb 指令,CPU 首先将本地 Invalidate Queue 的条目全部标记,并且强制要求 CPU 随后的所有读操作,务必等待 Invalidate Queue 中被标记的条目真正应用到缓存后方能执行。这就很好解决了上面的重排序问题,但同理,会带来一定程度的性能损耗。
读内存屏障 vs 写内存屏障
还有一个小问题,smp_mb 包含的语义有些“重”,既包含了 Store Buffer 的 flush,又包含了 Invalidate Queue 的等待环节,但现实场景下,我们可能只需要与其中一个数据结构打交道即可。于是,CPU 的设计者把 smp_mb 屏障进一步拆分,一分为二, smp_rmb 称之为读内存屏障,smp_wmb 称之为写内存屏障。他们分别的语义也相应做了简化:
- smp_wmb(StoreStore):执行后需等待 Store Buffer 中的写入变更 flush 完全到缓存后,后续的写操作才能继续执行,保证执行前后的写操作对其他 CPU 而言是顺序执行的;
- smp_rmb(LoadLoad):执行后需等待 Invalidate Queue 完全应用到缓存后,后续的读操作才能继续执行,保证执行前后的读操作对其他 CPU 而言是顺序执行的;
回到 Java 语言,JVM 是如何实现自己的内存屏障的?抽象上看 JVM 涉及到的内存屏障有四种:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1;LoadLoad;Load2 | 该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作 |
StoreStore Barriers | Store1;StoreStore;Store2 | 该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作 |
LoadStore Barriers | Load1;LoadStore;Store2 | 确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作 |
StoreLoad Barriers | Store1;StoreLoad;Load2 | 该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令 |
JVM 是如何分别插入上面四种内存屏障到指令序列之中的呢?这里的设计相当巧妙。
对于 volatile 读 or monitor enter
int t = x; // x 是 volatile 变量
[LoadLoad]
[LoadStore]
<other ops>
对于 volatile 写 or monitor exit
<other ops>
[StoreStore]
[LoadStore]
x = 1; // x 是 volatile 变量
[StoreLoad] // 这里带了个尾巴
不同架构下的实现
不同的处理器平台,本身的内存模型有强(Strong)弱(Weak)之分。实际实现的时候由于某些指令集之间的关系使得 memory barrier 的实现不可能做到最优,很多常见的平台都使用了简单粗暴的 bus 锁(x86、amd64、armv7)
- Weak Memory Model: 如DEC Alpha是弱内存模型,它可能经历所有的四种内存乱序(LoadLoad, LoadStore, StoreLoad, StoreStore),任何Load和Store操作都能与任何其它的Load或Store操作乱序,只要其不改变单线程的行为。
- Weak With Date Dependency Ordering: 如ARM, PowerPC, Itanium,在Aplpha的基础上,支持数据依赖排序,如C/C++中的A->B,它能保证加载B时,必定已经加载最新的A
- Strong Memory Model: 如X86/64,强内存模型能够保证每条指令acquire and release语义,换句话说,它使用了LoadLoad/LoadStore/StoreStore三种内存屏障,即避免了四种乱序中的三种,仍然保留StoreLoad的重排,
- Sequential Consistency: 最强的一致性,理想中的模型,在这种内存模型中,没有乱序的存在。如今很难找到一个硬件体系结构支持顺序一致性,因为它会严重限制硬件对CPU执行效率的优化(对寄存器/Cache/流水线的使用)。
对于x86架构来说,store buffer是FIFO,因此不会存在乱序,写入顺序就是刷入cache的顺序。但是对于ARM/Power架构来说,store buffer并未保证FIFO,因此先写入store buffer的数据,是有可能比后写入store buffer的数据晚刷入cache的。从这点上来说,store buffer的存在会让ARM/Power架构出现乱序的可能。store barrier存在的意义就是将store buffer中的数据,刷入cache。
在某些cpu中,存在invalid queue。invalid queue用于缓存cache line的失效消息,也就是说,当cpu0写入W0(x, 1),并从store buffer将修改刷入cache,此时cpu1读取R1(x, 0)仍是允许的。因为使cache line失效的消息被缓冲在了invalid queue中,还未被应用到cache line上。这也是一种会使得指令乱序的可能。load barrier存在的意义就是将invalid queue缓冲刷新。
对于x86架构的cpu来说,在单核上来看,其保证了Sequential consistency,因此对于开发者,我们可以完全不用担心单核上的乱序优化会给我们的程序带来正确性问题。在多核上来看,其保证了x86-tso模型,使用mfence就可以将store buffer中的数据,写入到cache中。而且,由于x86架构下,store buffer是FIFO的和不存在invalid queue,mfence能够保证多核间的数据可见性,以及顺序性。
对于arm和power架构的cpu来说,编程就变得危险多了。除了存在数据依赖,控制依赖以及地址依赖等的前后指令不能被乱序之外,其余指令间都有可能存在乱序。而且,它们的store buffer并不是FIFO的,而且还可能存在invalid queue,这些也同样让并发编程变得困难重重。因此需要引入不同类型的barrier来完成不同的需求。
我们接下来着重讨论 X86 平台下,Java volatile 关键字是如何实现防止指令重排序的。
根据上面表格我们可以看到 x86 平台下,只有 StoreLoad 才有具体的指令对应,而其他三个屏障均是 no-op (空操作)。
关于 StoreLoad 又有三个具体的指令对应,分别是 mfence、cpuid、以及 locked insn,他们都能很好地实现 StoreLoad 的屏障效果。但毕竟不可能同时用三种指令,这里可能意思是,三种均能达到效果,具体实现交由 JVM 设计者决断。
我们随便写一段代码,查看下JVM采用的是哪一种命令(需要下载 hsdis-amd64.dylib 然后移动到 jre lib 目录)
javac VolatileTest.java && java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly
public class VolatileTest {
volatile static int a = 1;
public static void main(String[] args) {
test();
}
public static void test(){
a++;
}
}
可以看到这里的 StoreLoad 用到的具体指令是lock
0x000000010dfedef8: lock addl $0x0,(%rsp)
0x000000010dfedefd: cmpl $0x0,-0x32f1197(%rip) # 0x000000010acfcd70
; {external_word}
0x000000010dfedf07: jne 0x000000010dfedf1b
lock用于在多处理器中执行指令时对共享内存的独占使用。它的副作用是能够将当前处理器对应缓存的内容刷新到内存,并使其他处理器对应的缓存失效。另外还提供了有序的指令无法越过这个内存屏障的作用。
简单来说,这句指令的作用就是保证了可见性以及内存屏障。
- 执行 a 的写操作后执行到 StoreLoad 内存屏障;
- 发出 Lock 指令,锁总线 或 a 的缓存行,那么其他 CPU 不能对已上锁的缓存行有任何操作;
- 让其他 CPU 持有的 a 的缓存行失效;
- 将 a 的变更写回主内存,保证全局可见;
上面执行完后,该 CPU 方可执行后续操作。
volatile与原子性
我们看到jvm通过lock实现了volatile的内存屏障,但是volatile并不具有原子性。原因很简单,不同 CPU 依旧可以对同一个缓存行持有,一个 CPU 对同一个缓存行的修改不能让另一个 CPU 及时感知,因此出现并发冲突。线程安全还是需要用锁来保障,锁能有效的让 CPU 在同一个时刻独占某个缓存行,执行完并释放锁后,其他CPU才能访问该缓存行。
MESI和volatile的联系
本文我们通过cpu的缓存介绍了cpu的缓存一致性协议,同时又引出了内存屏障。在多核cpu中通过缓存一致性协议保证了每个缓存中使用的共享变量的副本是一致的。
当一个CPU进行写入时,首先会给其它CPU发送Invalid消息,然后把当前写入的数据写入到Store Buffer中。然后异步在某个时刻真正的写入到Cache中。当前CPU核如果要读Cache中的数据,需要先扫描Store Buffer之后再读取Cache。但是此时其它CPU核是看不到当前核的Store Buffer中的数据的,要等到Store Buffer中的数据被刷到了Cache之后才会触发失效操作。而当一个CPU核收到Invalid消息时,会把消息写入自身的Invalidate Queue中,随后异步将其设为Invalid状态。和Store Buffer不同的是,当前CPU核心使用Cache时并不扫描Invalidate Queue部分,所以可能会有极短时间的脏读问题。MESI协议,可以保证缓存的一致性,但是无法保证实时性。所以我们需要通过内存屏障在执行到某些指令的时候强制刷新缓存来达到一致性。
但是MESI只是一种抽象的协议规范,在不同的cpu上都会有不同的实现,对于x86架构来说,store buffer是FIFO,写入顺序就是刷入cache的顺序。但是对于ARM/Power架构来说,store buffer并未保证FIFO,因此先写入store buffer的数据,是有可能比后写入store buffer的数据晚刷入cache的
而对于JAVA而言,他必须要屏蔽各个处理器的差异,所以才有了java内存模型(JMM),volatile只是内存模型的一小部分,实现了变量的可见性和禁止指令重排序优化的功能。整个内存模型必须要实现可见性,原子性,和有序性。而volatile实现了其中的可见性和有序性。
参考资料:
《Memory Barriers: a Hardware View for Software Hackers》