• 13.深入浅出高速缓存带来的可见性问题


    除了编译器优化等导致重排序,还有一种情况也会导致可见性问题,那就是高速缓存的存在,本节我们就来分析一下。

    本节涉及的内容,例如缓存一致性协议等,在网上能找到很多介绍,但是其转移过程都非常复杂,我们重点深入浅出解释该问题。

    1 总线锁和缓存锁

    我们知道在计算机中CPU是速度最快的,而磁盘IO等是相对比较慢的,因此为了提高CPU的处理效率会在多个层次增加缓存。首先是CPU内核就有寄存器作为缓存,另外还有数据缓存和指令缓存。这两级都是在CPU内的,而CPU和IO设备之间还有一层缓存,这就是CPU的三级缓存。如下图所示:

    这时候很明显的问题就出现了,如果一个CPU修改了数据,而其他CPU并不知道,就可能使用错误的数据继续进行其他处理,那么自然结果就错了。这就是CPU的缓存导致一致性出现问题的原因。

    为了解决该问题就有两种基本的方法:总线锁和缓存锁,而缓存锁主要是指缓存一致性协议。

    我们先明确一下什么是总线,所谓总线就是CPU与内存、IO设备之间的公共通道,也就是上图中蓝色标记的区域。当CPU访问内存时,必须经过总线锁。

    而总线锁就是在总线上声明一个Lock#信号,这个信号能保证共享内存只有当前CPU可以访问,其他的处理器请求时都会被阻塞,这样就可以保证同一个时刻只有一个处理器能访问共享内存,从而解决了不一致的问题。这么做的代码也很明显,就是CPU的利用率严重下降。

    为此,处理器又推出了缓存锁机制。意思是说如果当前CPU访问的数据已经缓存在其他CPU的高速缓存中,那么CPU不会总再总线上声明Lock#信号,而是采用缓存一致性协议来保证多个CPU的缓存一致性问题。

    2.缓存一致性协议

    缓存锁是通过缓存一致性协议来保证缓存一致性的,不同CPU支持的类型有所差异,目前使用最多的是MESI协议,该协议被应用在Intel奔腾系列的CPU中。

    MESI是Modified、Exclusive、Shared、Invalid这四个单词的首字母。这4个字母分别代表4种状态:该协议的原理很简单,就是在MESI协议中,定义了几个不同情况下的状态,每个Cache的Cache控制器不仅知道自己的读写操作,而且也监听(snoop)其它Cache的读写操作。每个Cache line所处的状态根据本核和其它核的读写操作在4个状态间进行迁移。

    状态描述
    Modified(修改)这行数据有效,数据被修改了,和内存中的数据不一样,数据只存在于本cache中。
    Exclusive(互斥)这行数据有效,数据和内存中的数据一致,数据只存下于本Cache中  
    Shared(共享)这行数据有效,数据和内存中的数据一致,数据存在于很多cache中  
    Invalid(无效)这行数据无效

    我们结合图示再看一下上述几种状态是啥意思。

    E状态

    只有Core 0访问变量x,它的Cache line状态为E(Exclusive)。

    S状态

    3个Core都访问变量x,它们对应的Cache line为S(Shared)状态。

    M状态和状态之间的转化

    Core 0修改了x的值之后,这个Cache line变成了M(Modified)状态,其他Core对应的Cache line变成了I(Invalid)状态。

    状态明确之后,那相互之间是如何迁移的呢?这个过程描述起来非常复杂,我们只看简化的情况:

    假如只有两个CPU核,如图1所示,当单个CPU从主内存中读取一个数据保存到高速缓存中时,具体的流程是CPU0发出从内存中读取x变量的指令,主内存通过总线返回数据后缓存到CPU0的高速缓存中 ,并且设置该缓存状态为E。

    此时如果CPU1同样发出一条针对x的读取指令,如图2所示,那么当CPU0检测到时会针对该消息做出响应,将缓存在CPU0里的x通过Read Response消息返回给CPU1,此时x分别存储在CPU0和CPU1的高速缓存中,所以x的状态被设置为S。

    然后CPU0把x变量的值修改成x=30,把自己的缓存状态设置为E,接着把修改后的值写入内存,此时x的缓存行是共享状态,同时需要发送一个Invalidate消息给其他缓存,CPU1收到该消息之后,把高速缓存中的x设置为Invalid,最终得到如下的结构:

     也许这里你会有个疑问,图3中Cache的状态应该是E还是M呢?我的理解是如果将x=30同步到内存之前就是M,同步到内存之后就是E。

    根据上面的描述,我们可以看到,CPU的高速缓存导致了缓存一致性的问题,为此,CPU层面提供了总线锁和缓存锁的机制。基本思想是通过LOCK#信号触发总线锁和缓存锁,如果不支持总线锁。则会使用缓存一致性协议来保证缓存一致性协议。其目标都是为了保证同一时刻只允许一个CPU堆共享内存进行读写操作。

    总结

    说了这么多,那volatile到底做了什么呢?其实就是JVM看到某个变量有volatile之后,调了一下底层的Lock#指令而已。其他的都交给CPU和总线等来处理了。

    说了这么多,与volatile有啥关系呢?Volatile一个关键字将上面的内容全给实现了,或者调用底层的服务来实现了。我们可以看一下,当我们对一个变量加了volatile关键字之后在不同的层次会有什么变化,具体包括:

    • 代码层面: volatile关键字

    • 字节码层面:ACC_VOLATILE字段访问标识符

    • JVM层面:JMM要求实现为内存屏障。

    • (Hospot)系统底层: 读volatile基于c++的volatile关键字,每次从主存中读取。 写volatile基于c++的volatile关键字和 lock 指令的内存屏障,每次将新值刷新到主存,同时其他cpu缓存的值失效。 C++的volatile禁止对这个变量相关的代码进行乱序优化(重排序),也就具有内存屏障的作用了。

    • Linux内核也可以手动插入内存屏障:_ asm _ _ volatile _ ( " " : : : "memory" )。这样就控制CPU的执行按照的执行了。

    拓展

    这里补充一个概念,上面图中我们用的是缓存行而不是缓存,而且这里会引申出另外一个问题——伪共享问题,感兴趣的同学可以研究一下。

  • 相关阅读:
    爱了爱了,Alibaba顶级MySQL调优手册到手,加薪妥了
    很全很详细的GUI编程
    flask 框架web开发视频笔记
    解决Spring Boot启动错误的技术指南
    天龙八部科举答题问题和答案(全7/8)
    编译原理7:语法分析、消除左递归、FIRST/FOLLOW集合
    手写自定义springboot-starter,感受框架的魅力和原理
    架构师的 36 项修炼第06讲:架构核心技术之微服务
    策略模式-C++实现
    java八股文_1
  • 原文地址:https://blog.csdn.net/xueyushenzhou/article/details/126653538