内存模型,英文名Memory Model,是一个跟计算机硬件相关的概念。
内存模型的故事,要从CPU高速缓存讲起。有了高速缓存后,在普遍多核CPU下,又有了缓存一致性的问题,为了解决一致性问题,又有了缓存一致性协议

CPU高速缓存

在计算机中,IO速度高低以数量级递减:CPU->主内存->磁盘。相对CPU,对主内存的IO是一项非常昂贵的操作。为了不让CPU在操作内存时耗费过多等待时间,人们在CPU和主内存之间增加了CPU高速缓存
缓存的作用就是保存一份数据拷贝,特点是IO速度快,容量小,昂贵
CPU高速缓存的速度介于CPU和主内存之间,CPU进行计算时,可以直接对高速缓存上的拷贝数据进行读取/写入操作,大大提升了CPU的执行效率。

CPU基于高速缓存执行的流程

CPU每18个月运算速度翻一倍,为了更好的利(压)用(榨)CPU运算能力,又引入了2级甚至3级缓存结构。在多核CPU的情况下,每个核都有自己的1级/2级缓存。如图所示:

多级缓存

缓存一致性

只有单核时,是没有问题的。当拥有多个核时,事情就变得很复杂。
当CPU0缓存了主内存某个数据的拷贝,当CPU1需要用到相同数据时,如何尽快的知道这个数据是否被修改?

如何保证缓存的一致性?

如何保证缓存一致性,需要多个CPU之间进行交互,这种交互的规则叫做缓存一致性协议
不同的硬件厂商有不同的缓存一致性协议,大部分协议都有很多共同点。常用的协议就是MESI

一致性协议: MESI

MESI是4种状态首字母的组合。我们可以把MESI理解为基于状态的一致性协议。

我们来试试拆解成相对简单的几个概念来帮助理解:

  • 状态
  • 触发事件
  • 状态迁移
  • 读写的协同操作

状态

状态,描述的是 缓存行(Cache Line): CPU高速缓存存储数据的单元。
在MESI中,缓存行有M/E/S/I四种状态,(每个缓存行会用2个bit表示当前状态)。

状态 描述
M (Modified) 修改:数据有效,已被修改,与主内存中的数据不一致,只存在于本cache中
E (Exclusive) 独占:数据有效,和主内存一致
S (Shared) 共享:数据有效,和主内存一致,数据存于多个cache中
I (Invalid) 无效:这条数据无效。可能是其它cpu更改了这条数据

触发事件

缓存行的状态的变更,是由触发事件触发,事件有4种:

事件 描述 触发源
本地读取 Local Read 本地cache读取本地cache的值 本地cache
本地写入 Local Write 本地cache写入本地cache的值 本地cache
远端读取 Remote Read 其它cache读取本地cache的值 remote cache
远端写入 Remote Write 其它cache写入本地cache的值 remote cache

状态迁移

我们假设有一条缓存行被所有cache共同缓存了。针对这条缓存行,我们以不同的cache为视角,在每个状态时,触发了每一种事件后,状态迁移过程。

名词解释:

  • 本地cache: 指当前cache
  • 触发cache: 指触发读写事件的cache
  • 其它cache: 指除了本地cache和触发cache外的其它cache
    local read, local write两种本地事件,本地cache和触发cache相同

注: 迁移事件描述是以本地cache为视角。

Local Read Local Write Remote Read Remote Write
M 本地cache:M
其它cache:I
本地cache:M
其它cache:I
本地cache:M->E->S
->触发cache:I->S
其它cache:I
本地cache:M->E->S->I
触发cache:I->S->E->M
其它cache:I
E 本地cache:E
其它cache:I
本地cache:E->M
其它cache:I
本地cache:E->S
触发cache:I->S
其它cache:I
本地cache:E->S->I
触发cache:I->S->E->M
其它cache:I
S 本地cache:S
其它cache:S
本地cache:S
其它cache:S
本地cache:E->I
其它cache:E->I
触发cache:S->E->M
本地cache:S->I
触发cache:S->E->M
其它cache:S->I
I 本地cache:I->S/E
E、M、I->S
本地cache:I->S->E->M
其它cache:M、E、S、I->I
不变 不变

如图所示:

同一份数据在不同缓存里,可以有的状态:

M E S I 描述
M × × × 当一个cache中的缓存行状态为M, 该缓存行在其它cache均为I
E × × × 当一个cache中的缓存行状态为E, 该缓存行在其它cache均为I
S × × 当一个cache中的缓存行状态为S, 该缓存行在其它cache均为S或者I
I 缓存行状态为I, 其它cache中可以为任意状态

读写的协同操作

上面描述了不同MESI各种状态,迁移事件,以及状态迁移过程。现在我们来梳理一下多个CPU对缓存时的协同操作。

假设在主内存中变量int x = 0,现在我们有两个CPU及Cache要对这个变量进行读写的操作。

在主内存和各个Cache中间是有BUS这一层消息总线的设计,负责消息传递。为了更直观理解,这里画的是时序图忽略了BUS这一层。

读取数据

CPU CacheA 开始第一次读取主内存的x值。
读取后CacheA拥有x的缓存行数据,该缓存行状态为E;

接下来,CPU CacheB 从主内存读取x值;
此时,CPU CacheB的缓存行的状态为S;
CPU CacheA检测到地址冲突,CPU CacheA的缓存行状态相应迁移为S;
此时两个Cache的缓存行都为S状态;

修改数据

此时两个Cache的缓存行状态都为S;现在CPU A开始修改x的值为1。
具体执行流程:

  1. CPU A将x状态设置为M;
  2. 通知CPU B;
  3. CPU B收到通知,将x状态设置为I;
  4. CPU B通知CPU A
  5. CPU A收到通知,对x进行赋值为1;

在CPU A修改S状态的数据前,需要先发消息通知给其它CPU,且拿到其它CPU的确认消息后(缓存行状态改为了I),才会修改数据;

同步数据

数据被CPU A修改之后,CPU B需要读取最新的x数据。
具体执行流程:

  1. CPU B发出指令:读取x
  2. CPU B通知CPU A
  3. CPU A将修改后的数据同步到主内存,
  4. CacheA的缓存行状态改为E
  5. 通知CPU B
  6. CPU B同步最新的数据到Cache,状态为S
  7. CacheA的缓存行状态改为S

CPU B在同步I状态的数据之前,需要先同步其它Cache的数据;

MESI性能优化

我们可以看到,在读写的协同操作时,在状态迁移前会有多次的消息传递。消息传递是需要时间的,这会使得状态切换需要更多的延迟。
而有些特殊状态的切换需要特殊处理,可能会阻塞CPU。这会造成稳定性问题和性能问题。

如S状态->M状态,需要发送消息给所有其它持有缓存行的Cache,且拿到确认消息才能进行下一步。

存储缓存(Store Bufferes)

为了避免这种CPU阻塞造成的浪费,引入了Store Bufferes:
处理器把它想写入的值写入缓存,然后继续处理其它事情。当所有失效确认(Invalidate Acknowlege)都接收到,数据才会最终被提交。

Store Bufferes的问题
第一:CPU想从存储缓存中读取值,但是它还没有被提交。此时我们可以直接加载该值。这个解决方案叫做Store Forwarding
第二:指令重排序的问题。
我们先看看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
value = 3

void exeToCPUA(){
value = 10;
isFinish = true;
}
void exeToCPUB(){
if(isFinish){
//value一定等于10?!
assert value == 10;
}
}

试想在开始执行时,CPUA的isFinish的数据是E状态,而value是I
此时,CPUA执行value = 10这行代码。由于value状态是I,CPU会先把这个值写入Store Bufferes,然后继续执行isFinish = true这行代码。等ACK消息确认完再执行value=10
isFinish由于是E状态,不需要消息传递,会被直接执行。
这就意味着,有可能存在这种情况:

isFinish=true的执行在value=10之前

这种可识别的行为中发生的变化,称为重排序(recordings)
注意:这不意味着指令的位置被恶意修改,只是意味着其它CPU会读到跟写入的顺序不一样的结果。

失效队列

执行失效这个操作本身很复杂,有多次的消息和消息确定(Store Bufferes就是专门干这个的)。而存储缓存并不是无穷大,达到上限时CPU需要等待失效确认,从而造成阻塞。——阻塞就意味着性能浪费。失效队列的引入就是为了应付这种情况。它们的约定如下:

  • 对于所有的收到的Invalidate请求,Invalidate Acknowlege消息必须立刻发送。
  • Invalidate并不真正执行,而是被放在一个特殊的队列中,在方便的时候才会去执行。
  • 处理器不会发送任何消息给所处理的缓存条目,直到它处理Invalidate

这里要强调,失效队列仅仅是为了应付这种性能低下的问题,而没有彻底解决它。即便引入了失效队列,CPU还是不知道什么时候该优化,什么时候不该优化。
硬件工程师们干脆把这个问题交给了软件工程师,由代码本身来决定什么时候该优化,什么时候不该优化。这就是大名鼎鼎的内存屏障(Memory Barriers)

内存屏障(Memory Barriers)

内存屏障,核心就是两个指令:

  • Store Memory Barrier(a.k.a. ST, SMB, smp_wmb)指令:告诉处理器在执行这之后的指令之前,应用所有已经在存储缓存(store buffer)中的保存的指令。
  • Load Memory Barrier (a.k.a. LD, RMB, smp_rmb)指令:告诉处理器在执行任何的加载前,先应用所有已经在失效队列中的失效操作。

现在我们来应用一下这两条指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
value = 3

void exeToCPUA(){
value = 10;
//在更新数据之前必须将所有存储缓存(store buffer)中的指令执行完毕。
storeMemoryBarrier();
isFinish = true;
}
void exeToCPUB(){
if(isFinish){
//在读取之前将所有失效队列中关于该数据的指令执行完毕。
loadMemoryBarrier();
assert value == 10;
}
}

好了,现在程序就能完美的安全的执行了。

内存屏障的设计,给了应用软件一个重要的性能优化工具。同时提出了两个挑战:

  1. 软件设计的复杂度大幅上升
  2. 跨平台的问题

对于第一个问题,严重依赖软件工程师的经验和能力。—— 所以有经验的程序猿薪资都很高
对于第二个问题,作为Java工程师应该很庆幸,举着Write once, run everywhere大旗的Java,设计了整套Java内存模型(Java Memory Model)解决了跨平台问题。

结束语

开篇介绍了内存模型的硬件背景:CPU高速缓存 + 多核CPU

CPU高速缓存的存在,是为了减少CPU的阻塞,从而提升计算性能;多核CPU的存在,是为了提升整体的计算性能;

然而应用高速缓存是有代价的,这个代价就是多个CPU高速缓存之间的数据同步问题(即内存可见性)。因此引出了MESI:一致性协议

接下来介绍了MESI的状态迁移事件迁移过程等基本概念。并在此基础上推演了在MESI下的读写协同操作,这时我们自然发现在协同操作的时候,会遇到CPU阻塞问题。因此引入了Store Bufferes失效队列等机制。

Store Bufferes失效队列等机制,是为了减少CPU阻塞从而提升计算性能;

到这里,CPU的性能实际上是有了大幅提升。在通用场景下,软件应用不用做太多处理就能获得高性能。
然而在一些复杂业务场景下,如重排序带来的数据同步问题不可避免的造成CPU阻塞。CPU阻塞的问题依然在。
CPU阻塞不可完全避免,却要尽量去避免。硬件工程师提供了内存屏障这个武器,使得软件工程师们有了工具和可能,去解决复杂业务场景下高性能的问题。

内存屏障的设计,同样是为了减少CPU阻塞从而提升计算性能;

很有意思的是,这些眼花缭乱的机制和设计,都是内存可见性和CPU计算性能之间相爱相杀的故事。

那么内存模型到底是什么呢?
这篇文章里提到的内存模型,其实是指硬件内存模型:在多核多CPU,每个CPU有私有内存缓存的硬件背景下,各个CPU之间如何以一种统一的方式(如MESI)来与内存交互。

对Java而言,高并发编程设计的基础是Java内存模型。Java内存模型是硬件内存模型在JVM平台的应用。所以内存模型,对我们深入理解Java高并发编程是非常有意义的。
至于Java如何应用内存模型,请关注Java内存模型(Java Memory Model)。

参考文章

深入Java内存模型