3 CPU缓存一致性协议MESi

2020-09-27 17:04:30 浏览数 (1)

之前说了volatile加在全局变量上, 可以保证变量的可见性. 那么volatile到底是怎么保证变量的可见性的呢?

首先, 我们来说一下, java代码是怎么执行的.

一、java代码从jvm虚拟机到底层cpu等硬件是如何交互运行的?

先来看看程序代码在jvm虚拟机层面是如何工作的

代码语言:javascript复制
    package com.alibaba.nacos.test;

    /**
     * Description
     * <p>
     * </p>
     * DATE 2020/8/31.
     *
     * @author luoxiaoli.
     */
    public class CodeVisiable {
        private static boolean initFlag = false;


        public static void refresh() {
            System.out.println("refresh data.....");
            initFlag = true;
            System.out.println("refresh data success");
        }

        public static void main(String[] args) {
            // 线程A
            Thread threadA = new Thread(() -> {
                while (!initFlag) {

                }
                System.out.println("线程:"   Thread.currentThread().getName()   "当前线程秀谈到initFlag的状态已经改变");
            }, "threadA");
            threadA.start();

            // 中间休眠500hs
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            // 线程B
            Thread threadB = new Thread(() -> {
                while (!initFlag) {
                    refresh();
                }
            }, "threadB");
            threadB.start();
        }
    }

以这个代码为例来说明:

第一步. 类CodeVisiable会被类加载器ClassLoader加载, 加载完以后, 将类基本信息放入元数据区-->a.class, 然后会在堆里面创建一个class对象.

第二步: 程序如果要想运行, 首先要启动一个线程

   然后加载元数据区的方法, 比如refresh()方法. 启动线程后, 首先, 会在线程栈开辟一块栈帧, 然后执行操作数栈  

    操作数栈第一步, 就是获取一个常量. 将其压入栈,

    然后字节码执行引擎, 调用常量iconst0, 执行字节码操作

如上面的步骤, 这样程序代码在jvm中的流程就结束了

如果说从jvm的角度来说, jvm的流程是结束了, 但是, 仔细思考, 整个 JVM运行时数据区, 还有开辟的线程0, 都是在那里呢? 都是在内存里的.

但我们知道如果要想执行iconst0这个变量, 需要谁来调度? 需要cpu来调度. 忘内存里写入一个值, 需要有cpu来控制.

变量iconst0到底是怎么放进去的呢?

刚开始iconst0是在内存空间的.   

iconst0是jvm字节码执行引擎才认识的代码, 如果想要往操作数栈中写值, 那么它对应的逻辑必须要放亏到cpu上, 而cpu只认0101的二进制.

jvm字节码执行引擎, 内置了两个解释器, 一个叫做JIT, 以及叫做解释执行器. 所以consts0这个常量的字节码, 会被解释执行器/JIT进行翻译, 翻译成汇编指令.

为什么说java慢, 相对于c来说, java很慢, 他慢就慢在这里了.

那么, 汇编指令能够直接在cpu上执行么?

当然是不可以的, 因为汇编指令是硬件原语. 还需要将汇编指令翻译成二进制代码, 也就是cpu能够识别的语言. 这个过程是很快的.

这过程中, 将字节码翻译成汇编指令也就是硬件原语, 是在软件层面上操作的,速度更慢. 将汇编指令翻译成二进制, 是在硬件成面的, 速度相对快一些

所以, 我们看到, 我们的一个java代码至少要被执行两次, 才能被放到cpu上执行.

此时. 只是具备了被cpu执行的可能. 还不能被执行. 什么时候才能被执行呢?

这里虽然准备好了指令以及数据, 但是cpu并不是说马上就会执行, 而是当二进制代码所在的线程被cpu调用了, cpu才会执行二进制代码

cpu怎么知道, 什么时候来调度线程呢?

我们知道cpu的内核又两种KLT和ULT, jvm使用的是klt

其实,在OS底层,有线程变量池, 线程变量池里的线程和我们的线程栈是1对1的关系.

当cpu调度到线程变量池的某个线程的时候, 就会去执行这个线程的二进制代码.

二. volatile的可见性问题: volatile是如何保证可见性的呢?

就是依赖硬件原语(汇编语言) 给我们提供的这个功能.

下面看看一个变量加了volatile以后, 底层到底做了什么. 想要看到底层的源码,

第一步: 我们需要下载一个额外的插件

这两个插件包, 第一个对应的是64位操作系统, 第二个对应的是32位操作系统.

第二步: 解压后, 有两个文件 ,将这两个文件放到如下目录下
第三步: 再启动配置上增加启动参数.
代码语言:javascript复制
-server
-Xcomp
-XX: UnlockDiagnosticVMOptions
-XX: PrintAssembly
-XX:CompileCommand=compileonly.*Jmm03_CodeVisibility.refresh

其中,标红的那段代码, 就是会把汇编指令打印出来

mac版本下载地址和使用方法参考: https://blog.csdn.net/iter_zc/article/details/41897137?readlog

运行程序, 我们看到, 会打印出来一个汇编指令码

其中, 加了volatile关键字的变量, 在执行到第31行, 写volatile的时候, 加了一个锁. 加锁的那一个行代码是第31行. 刚好就是initFlag=true这一行

我们来看看这个锁是什么意思呢?

查找手册, 我们发现, LOCK的含义是, 加了一个总线锁.

lock会触发硬件缓存锁定机制, 锁定机制有两种: 总线锁和缓存一致性协议

为什么会有两种锁呢? 这就和cpu的发展有关系了.

早期的cpu技术比较落后, 才使用的总线锁, 来保存缓存的一致性.

总线: cpu想要访问内存条, 必须要通过总线去访问, 如下图. 如果有多个cpu想要同时访问内存条, 就需要获取总线的锁, 谁获取到锁了, 谁就能访问内存条.

可以看到这种方法的缺点, 一旦抢到锁, 那么只有这个cpu可以执行,其他cpu就没有办法在访问内存里的这个变量了. 没有办法发挥多核并发的能力.

因此发展出来了缓存一致性协议. 现在使用最普遍的是mesi协议,

三. mesi协议的工作原理

四个字母分别代表在缓存里不同的四个状态: M:已修改 E:独占 S:共享 I:已失效

MESI 是4种状态的首字母。每个Cache line有4个状态,可用2个bit表示,它们分别是:

缓存行(Cache line):缓存存储数据的单元。

状态

描述

监听任务

M 修改 (Modified)

该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。

缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。

E 独享、互斥 (Exclusive)

该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。

缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。

S 共享 (Shared)

该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。

缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。

I 无效 (Invalid)

该Cache line无效。

注意:

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

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

如上图, 一共有4个状态, 那么, 这四个状态之间是如何转换的呢?

计算机启动的时候, cpu会启动一个监控程序, 监控总线中被lock标记的变量. 我们知道加了volatile的变量, 就被lock标记了. 那么被lock标记后的变量是如何工作的呢? 一initFlag为例说明

1. 计算器启动, 有两个cpu, 那么两个cpu都会监听bus总线上带有lock标记的变量

2. 内存中有一个变量initFlag=false.

3. cpu core0 要调用initFlag, 这时候 ,首先拷贝一份initFlag 放入到bus总线, bus总线监控到initFlag带有lock标记, 于是所有cpu都监控到这个变量.

4. 然后将initFlag 拷贝到L3 cache-->L2 cache ,此时, 只有一个cpu使用到这个变量, 所以, initFlag此时的状态是独享的状态.

5. 另一个cpu core1 也要调用initFlag变量, 通过bus总线监控到已经有线程在使用这个变量了, 于是, cpu core0也监控到cpu core1 使用这个变量了, 此时, 将initFlag的状态由独占变为共享状态. 同时cpu core1的中initFlag的状态也是共享状态.

6. 接下来, cpu core1和cpu core2都想要去修改这个变量, 是如何操作的呢? 我们知道, 在缓存中, 有一个缓存行, 变量保存在缓存行里, 每个cpu需要抢占锁, 然后锁住缓存行, 并告诉bus总线, 我抢到锁了, 监听bus总线的所有cpu都将得知, 当前已经有一个线程获取的锁, 我们要将这个变量丢弃, 于是变量从共享状态变为丢弃状态.

7. 获得锁的cpu, 修改变量, 这时变量的状态从共享状态变为修改状态. 然后重新协会到主内存, 在经过bus总线的时候, 所有cpu都被告知initFlag变脸已经被修改, 需要重新获取新的initFlag变量.

8. 当两个线程同时修改initFlag, 并同时抢到锁, 怎么办呢? 他们会同时告诉bus总线, 我抢到锁了, 由bus总线裁决, 到底有谁来执行.

这就是volatile为何能够保证可见性的原因. 原因就是加了lock标记,

问题1: 一个缓存行64个字节, 那如果有个对象是128个字节, 怎么办呢?

缓存行本身是可以保证原子性的, 但是如果一个变量是128字节, 那怎么办呢? 跨缓存行就不是原子的了, 不是原子的, 缓存一致性协议就搞不定了, 缓存一致性协议就升级为总线锁了 ,谁抢到谁赢.

问题2: 既然最终都可以总线锁解决问题, 为什么还要用总线裁决呢?

因为: 总线裁决速度快, 效率高, 只需要裁决一下. 但总线锁要锁很久, 效率低. 总线裁决比总线锁快的多得多. 多数情况下, 总线裁决是可以解决问题的. 很少会遇到超过64字节的变量

四. volatile为什么不能保证原子性呢?

缓存一致性协议, 不能对寄存器生效.

上面那句话是什么意思呢?

比如: cpu core0 从内存里读取了一个volatile变量 counter = 0, 然后将其从L1缓存总将变量加载到寄存器进行计算. 计算完写回到L1 缓存, 此时, 变量的状态是修改, 然后通知bus总线, 所有的cpu都会监测到counter变量已经被修改, 丢弃自己现有的变量. 比如 cpu core1 此时会丢弃counter = 0, 但是如果counter已经被读取到寄存器进行计算了. 即使在L1内存中的数据被丢弃, 获取到了新的counter值, 当寄存器计算完以后, 会重新回写到L1缓存, 此时会覆盖刚刚读取到的counter=1, 将自己计算的counter=1写入内存中.

L1缓存中的变量有两种赋值方式, 一种是从内存加载进来, 另一种是从寄存器回写过来的.

因为缓存一致性协议只能失效缓存行的数据, 而不能失效寄存器的数据, 导致volatile不能做到原子性.

--------------------------------------以下是课件内容----------------------------------------------

1.1 MESI协议缓存状态

MESI 是4种状态的首字母。每个Cache line有4个状态,可用2个bit表示,它们分别是:

缓存行(Cache line):缓存存储数据的单元。

状态

描述

监听任务

M 修改 (Modified)

该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。

缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。

E 独享、互斥 (Exclusive)

该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。

缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。

S 共享 (Shared)

该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。

缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。

I 无效 (Invalid)

该Cache line无效。

注意:

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

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

1.2 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 需要调整的状态。

下图示意了,当一个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。

双核读取

那么执行流程是:

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状态(共享)。

修改数据

那么执行流程是:

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

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

CPU A 对x进行赋值。

同步数据

那么执行流程是:

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

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

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

缓存行伪共享

什么是伪共享?

CPU缓存系统中是以缓存行(cache line)为单位存储的。目前主流的CPU Cache 的 Cache Line 大小都是64Bytes。在多线程情况下,如果需要修改“共享同一个缓存行的变量”,就会无意中影响彼此的性能,这就是伪共享(False Sharing)。

举个例子: 现在有2个long 型变量 a 、b,如果有t1在访问a,t2在访问b,而a与b刚好在同一个cache line中,此时t1先修改a,将导致b被刷新!

怎么解决伪共享?

Java8中新增了一个注解:@sun.misc.Contended。加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在jvm启动时设置 -XX:-RestrictContended 才会生效。

代码语言:javascript复制
@sun.misc.Contended
public final static class TulingVolatileLong {
    public volatile long value = 0L;
    //public long p1, p2, p3, p4, p5, p6;
}

MESI优化和他们引入的问题

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

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

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

Store Bufferes

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

这么做有两个风险

Store Bufferes的风险

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

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

代码语言:javascript复制
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会读到跟程序中写入的顺序不一样的结果。

顺便提一下NIO的设计和Store Bufferes的设计是非常相像的。

硬件内存模型

执行失效也不是一个简单的操作,它需要处理器去处理。另外,存储缓存(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)是一条告诉处理器在执行任何的加载前,先应用所有已经在失效队列中的失效操作的指令。

0 人点赞