概述

  在我们使用多线程共同执行写操作更改同一个共享变量时,会导致数据运算的不正确性。比较典型就是i++问题,在多线程共同对一个变量进行++操作时,我们很难得到正确的结果:

一个自增运算符是一个复合操作,至少包括三个 JVM 指令:”内存取值”、”寄存器增加 1”、”存值到内存”。这三个指令在 JVM 内部是独立进行的,中间完全可能会出现多个线程并发进行。

有的地方也说是四个操作:

  1. 获取当前变量的值,并且放入栈顶
  2. 将常量 1 放入栈顶
  3. 将当前栈顶中两个值(当前变量的值和 1)相加,并把结果放入栈顶
  4. 把栈顶的结果再赋值给当前变量
1
2
3
4
5
6
7
8
9
10
11
12
13
public class TestCounter {
private static int count = 0;

public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
System.out.println(Thread.currentThread() + "====>" + count++);
}
}).start();
}
}
}

  为了保证数据的正确性,计算机硬件使用了以下两种方式来处理这个问题:

  • 总线锁
  • 缓存一致性协议

总线锁

  早期为了解决多线程操作数据的并发问题,老的CPU(如 Intel486)使用的是总线锁。比如查看被volatile修饰的共享变量的汇编指令,会发现有一个lock的关键字。

  虽然总线锁在一定程度上解决了数据操作的并发问题,但是总线锁在使用时,处理器会提供一个LOCK#信号,当一个处理器在总线上输出此信号会独占共享锁,其他处理器的请求将被阻塞。此时,相对其他cpu而言,整体就是一个单核CPU,这样多核CPU的优势就无法发挥,导致处理效率十分低下。

缓存一致性协议(MESI)

  上文中我们了解到总线锁虽然解决了问题,但是仍旧有很大的弊端。为了解决这种互斥问题,新版 CPU(如 IA-32、Intel 64)使用的是一种叫做缓存一致性协议的解决方案。缓存一致性协议多种多样(MESI、MSI等),其中最常用的就是MESI缓存一致性协议

多级缓存架构

  在了解什么是MESI之前,我们有必要先了解一下当前流行的多级缓存架构:

img

  • L1 Cache,分为数据缓存和指令缓存,逻辑核独占
  • L2 Cache,物理核独占,逻辑核共享
  • L3 Cache,所有物理核共享

  级别越小的缓存,越接近CPU, 意味着速度越快且容量越少。

  • 存储器存储空间大小:内存>L3>L2>L1>寄存器;
  • 存储器速度快慢排序:寄存器>L1>L2>L3>内存;

L1是最接近CPU的,它容量最小,速度最快,每个核上都有一个L1 Cache(准确地说每个核上有两个L1 Cache, 一个存数据 L1d Cache, 一个存指令 L1i Cache);

L2 Cache 更大一些,速度要慢一些,一般情况下每个核上都有一个独立的L2 Cache;二级缓存就是一级缓存的缓冲器:一级缓存制造成本很高因此它的容量有限,二级缓存的作用就是存储那些CPU处理时需要用到、一级缓存又无法存储的数据。

L3 Cache是三级缓存中最大的一级,例如12MB,同时也是最慢的一级,在同一个CPU插槽之间的核共享一个L3 Cache。三级缓存和内存可以看作是二级缓存的缓冲器,它们的容量递增,但单位制造成本却递减。

当CPU运作时,它首先去L1寻找它所需要的数据,然后去L2,然后去L3。如果三级缓存都没找到它需要的数据,则从内存里获取数据。寻找的路径越长,耗时越长。所以如果要非常频繁的获取某些数据,保证这些数据在L1缓存里。这样速度将非常快。

什么是MESI

名词解释

  MESI也就是缓存一致性协议,对应如下四种状态:

状态 描述 监听任务
M 修改 (Modified) 该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。
E 独享、互斥 (Exclusive) 该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。 缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S 共享 (Shared) 该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。 缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
I 无效 (Invalid) 该Cache line无效。

Cache Line:缓存存储数据的单元。64字节,每个Cache line有4个状态,可用2个bit表示。

注意:

  对于M和E状态而言总是精确的,他们在和该缓存行的真正状态是一致的,而S状态可能是非一致的。如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。

  从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。

  MESI四种工作状态转换逻辑图如下所示:

MESI协议状态切换过程分析

1.触发事件

触发事件 描述
本地读取(Local read) 本地cache读取本地cache数据
本地写入(Local write) 本地cache写入本地cache数据
远端读取(Remote read) 其他cache读取本地cache数据
远端写入(Remote write) 其他cache写入本地cache数据

2.cache分类:

前提:所有的cache共同缓存了主内存中的某一条数据。

本地cache:指当前cpu的cache。

触发cache:触发读写事件的cache。

其他cache:指既除了以上两种之外的cache。

注意:本地的事件触发 本地cache和触发cache为相同。

上图的切换解释:

状态 触发本地读取 触发本地写入 触发远端读取 触发远端写入
M状态(修改) 本地cache:M 触发cache:M其他cache:I 本地cache:M 触发cache:M其他cache:I 本地cache:M→E→S触发cache:I→S其他cache:I→S同步主内存后修改为E独享,同步触发、其他cache后本地、触发、其他cache修改为S共享 本地cache:M→E→S→I触发cache:I→S→E→M其他cache:I→S→I同步和读取一样,同步完成后触发cache改为M,本地、其他cache改为I
E状态(独享) 本地cache:E触发cache:E其他cache:I 本地cache:E→M触发cache:E→M其他cache:I本地cache变更为M,其他cache状态应当是I(无效) 本地cache:E→S触发cache:I→S其他cache:I→S当其他cache要读取该数据时,其他、触发、本地cache都被设置为S(共享) 本地cache:E→S→I触发cache:I→S→E→M其他cache:I→S→I当触发cache修改本地cache独享数据时时,将本地、触发、其他cache修改为S共享.然后触发cache修改为独享,其他、本地cache修改为I(无效),触发cache再修改为M
S状态(共享) 本地cache:S触发cache:S其他cache:S 本地cache:S→E→M触发cache:S→E→M其他cache:S→I 当本地cache修改时,将本地cache修改为E,其他cache修改为I,然后再将本地cache为M状态 本地cache:S触发cache:S其他cache:S 本地cache:S→I触发cache:S→E→M其他cache:S→I当触发cache要修改本地共享数据时,触发cache修改为E(独享),本地、其他cache修改为I(无效),触发cache再次修改为M(修改)
I状态(无效) 本地cache:I→S或者I→E触发cache:I→S或者I →E其他cache:E、M、I→S、I本地、触发cache将从I无效修改为S共享或者E独享,其他cache将从E、M、I 变为S或者I 本地cache:I→S→E→M触发cache:I→S→E→M其他cache:M、E、S→S→I 既然是本cache是I,其他cache操作与它无关 既然是本cache是I,其他cache操作与它无关

  下表示意了,当一个cache line的调整的状态的时候,另外一个cache line 需要调整的状态。

M E S I
M × × ×
E × × ×
S × ×
I

  举例来说:假设cache 1 中有一个变量x = 0的cache line 处于S状态(共享)。

  那么其他拥有x变量的cache 2、cache 3等x的cache line调整为S状态(共享)或者调整为 I 状态(无效)。

多核缓存协同操作

  假设有三个CPU A、B、C,对应三个缓存分别是cache a、b、 c。在主内存中定义了x的引用值为0。

image-20211118212939783

单核读取

执行流程是:

CPU A发出了一条指令,从主内存中读取x。

从主内存通过bus读取到缓存中(远端读取Remote read),这是该Cache line修改为E状态(独享).

image-20211118213021861

双核读取

执行流程是:

CPU A发出了一条指令,从主内存中读取x。

CPU A从主内存通过bus读取到 cache a中并将该cache line 设置为E状态。

CPU B发出了一条指令,从主内存中读取x。

CPU B试图从主内存中读取x时,CPU A检测到了地址冲突。这时CPU A对相关数据做出响应。此时x 存储于cache a和cache b中,x在chche a和cache b中都被设置为S状态(共享)。

image-20211118213058810

修改数据

执行流程是:

CPU A 计算完成后发指令需要修改x.

CPU A 将x设置为M状态(修改)并通知缓存了x的CPU B, CPU B将本地cache b中的x设置为I状态(无效)

CPU A 对x进行赋值。

image-20211118213134046

同步数据

执行流程是:

CPU B 发出了要读取x的指令。

CPU B 通知CPU A,CPU A将修改后的数据同步到主内存时cache a 修改为E(独享)

CPU A同步CPU B的x,将cache a和同步后cache b中的x设置为S状态(共享)。

image-20211118213202758

MESI优化和他们引入的问题


​ 缓存的一致性消息传递是要时间的,这就使其切换时会产生延迟。当一个缓存被切换状态时其他缓存收到消息完成各自的切换并且发出回应消息这么一长串的时间中CPU都会等待所有缓存响应完成。可能出现的阻塞都会导致各种各样的性能问题和稳定性问题。

CPU切换状态阻塞解决-存储缓存(Store Bufferes)

​ 比如你需要修改本地缓存中的一条信息,那么你必须将I(无效)状态通知到其他拥有该缓存数据的CPU缓存中,并且等待确认。等待确认的过程会阻塞处理器,这会降低处理器的性能。应为这个等待远远比一个指令的执行时间长的多。

Store Bufferes

​ 为了避免这种CPU运算能力的浪费,Store Bufferes被引入使用。处理器把它想要写入到主存的值写到缓存,然后继续去处理其他事情。当所有失效确认(Invalidate Acknowledge)都接收到时,数据才会最终被提交。

这么做有两个风险

Store Bufferes的风险

第一、就是处理器会尝试从存储缓存(Store buffer)中读取值,但它还没有进行提交。这个的解决方案称为Store Forwarding,它使得加载的时候,如果存储缓存中存在,则进行返回。

第二、保存什么时候会完成,这个并没有任何保证。

1
2
3
4
5
6
7
8
9
10
11
value = 3
void exeToCPUA(){
value = 10;
isFinsh = true;
}
void exeToCPUB(){
if(isFinsh){
//value一定等于10?!
assert value == 10;
}
}

试想一下开始执行时,CPU A保存着finished在E(独享)状态,而value并没有保存在它的缓存中。(例如,Invalid)。在这种情况下,value会比finished更迟地抛弃存储缓存。完全有可能CPU B读取finished的值为true,而value的值不等于10。

即isFinsh的赋值在value赋值之前。

这种在可识别的行为中发生的变化称为重排序(reordings)。注意,这不意味着你的指令的位置被恶意(或者好意)地更改。

它只是意味着其他的CPU会读到跟程序中写入的顺序不一样的结果。

硬件内存模型

执行失效也不是一个简单的操作,它需要处理器去处理。另外,存储缓存(Store Buffers)并不是无穷大的,所以处理器有时需要等待失效确认的返回。这两个操作都会使得性能大幅降低。为了应付这种情况,引入了失效队列。它们的约定如下:

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

即便是这样处理器已然不知道什么时候优化是允许的,而什么时候并不允许。

干脆处理器将这个任务丢给了写代码的人。这就是内存屏障(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
void executedOnCpu0() {
value = 10;
//在更新数据之前必须将所有存储缓存(store buffer)中的指令执行完毕。
storeMemoryBarrier();
finished = true;
}
void executedOnCpu1() {
while(!finished);
//在读取之前将所有失效队列中关于该数据的指令执行完毕。
loadMemoryBarrier();
assert value == 10;
}