NightPxy 个人技术博客

从高速缓存说可见性与指令重排

Posted on By NightPxy

概述

线程不安全的本质出在哪里? 原子性,有序性可见性(java内存模型)
其实java内存模型还不够本质,比如为什么会有指令重排?
指令重排不能用jvm为了提升执行效率而产生指令重排,比如下面这个例子

int a=1
int b=2
//指令重排这两个语句,可以获得什么性能提升呢?

这篇总结下线程安全的原理

两个预备知识

高速缓存

高速缓存是如今计算机演进的一个阶段性的成就,很多设计都是从高速缓存思想来的
高速缓存的思想很简单:越靠近计算的地方读取越开
比如Linux的页缓存,比如这里要说到的CPU高速缓存

引入CPU缓存的目的,与引入内存的目的是完全一样的.简单来说就是,内存的读写其实远远跟不上CPU计算的速度的(CPU技术迭代非常快,而内存技术相对进展就慢的多了)
每个CPU都装了一块专属的小内存,作为CPU缓存(CPU缓存还可以分多级,这里就说一级)
相应的常规理解中的内存称之为主存

局部性原理

局部性原理也非常简单

  • 时间局部性
    如果一个数据被用到,那么之后再次被使用的几率会大增(参考缓存)
  • 空间局部性
    如果一个数据被用到,那么它的前后位置被使用的几率会大增(参考每个程序中都会大量用到的循环)

内存设计

因为高速缓存局部性两个思想,计算机中的内存是这样设计的

缓存行

缓存行是局部性原理的体现
即内存中,是按缓存行为单位进行读取的(绝大部分是64字节)

这也是为什么说数组并链表更加适应遍历的原因.因为每次读取至少读一个缓存行

  • 数组本身是一个连续内存空间,也就意味着数组的一次读取可以拉取到N个元素(免费的内存缓存加载)
  • 链表是一个离散内存空间,它的每一个元素读取都可能必须读一个缓存行,而事实上这个缓存行中只有很少的部分才是链表元素

MESI协议

工作内存(CPU内存)是专属的,这个专属有两层含义

  • CPU只允许写自己的工作内存
  • 不同工作内存CPU不得访问
    所以为了让CPU更好的工作,制定了MESI(缓存一致性协议)这个工作协议(缓存行中的两个Bit)
  • M M变量表示已经被CPU修改,但还未同步到主存
  • E E变量由某个CPU私有,即只出现在主存和某一个工作内存中
  • S S变量是共享的,即出现在主存和多个工作内存中
  • I 宣布某个变量失效

工作过程

M,E变量是没有问题的,因为这始终是某个工作内存之内的事情
问题出在S变量上,变更S变量的步骤是

  • 修改自己工作内存变量为M
  • 同步M变量到主存
  • 广播I请求,要求将某个变量标记无效
  • 其它工作内存接受到I通知后,重新从主存中读取变量

这其中有个巨大的问题,那就是通知阻塞
一个CPU修改后广播失效指令,如果必须等待其他所有CPU的失效ACK确认的话,就会引起阻塞
这个阻塞对性能影响是很大的,虽然它非常短(CPU指令级别),但是频次太高(想想程序中会有多少变量变更,每一个都会产生一个阻塞)

为了解决这个阻塞问题,还有两个机制

  • StoreBuffer
  • 失效队列

StoreBuffer

StoreBuffer是在修改动作CPU中
它是指修改动作CPU,在变更变量后会先将修改后变量存入自己的StoreBuffer中
这样CPU无需等待同步主存,在广播失效指令后无需ACK直接往后运行
对于变量变更而言,它优先读取自己StoreBuffer中的数据(本地线程自己的操作读取永远是正确的)

失效队列

失效队列是针对其他CPU而言的
它是指CPU收到一个失效指令后会立即发出ACK,但不会立即处理它,而是放到失效队列中在将来某一个合适的时候才会处理
也就是说,对于变量变更而言,其他CPU真正应用这个变更是一个完全异步的行为

再议伪共享,可见性,有序性

到这里 可见性与有序性就很容易理解了

  • 可见性
    对于变量变更而言,其他CPU(其他线程)真正能应用这个变更是两个完全异步的行为
    首先需要在修改动作CPU在某个合适的时候应用到主存,还要再自己的CPU在某个合适的时候应用到自己的工作内存
    而在此这两个异步彻底完成前,自己的CPU是完全感知不到变量变更了.
    也就是对变量变更不可见
  • 有序性
    也是因为这两个异步的关系,变量变更的应用是一种几乎不可预知的行为.
    在A-CPU而言,可能它应用的早,而在B-CPU而言,可能它应用的晚,这样在不同的CPU之间就会体现出时早时晚的无序现象
    指令重排不是一种优化行为,而是因为不同CPU通知异步带来的现象
  • 伪共享
    伪共享是发生在volatile上的一种隐式性能损耗
    因为volatile修饰下,是有写在读前保证立即应用变更的,但是如果几个volatile处于同一缓存行内就会出现A,B两个线程明明变更两个独立的变量,却不得不出现相互失效的情况
    (解决办法是补位,下文详述)

内存栅栏

因为工作内存隔离,StoreBuffer和失效队列的关系,不同工作内存之间的几乎处于一种不可预知的状态

解决的办法就是内存栅栏
内存栅栏,其实是一种CPU自己给自己设定的一种规则
内存栅栏分为读栅栏和写栅栏

  • 读栅栏 是针对其它CPU的失效队列而言的
    它的意思是,如果收到这个指令,立即应用如今失效队列中的所有指令,在失效队列完成前不得再继续执行
  • 写栅栏,是针对修改操作CPU的StoreBuffer而言的
    它的意思是,如果收到这个指令,立即将StoreBuffer中的数据同步到主存中并发出失效指令,在同步到主存完成前不得再继续执行

所谓写在读前,连起来的意思是
修改操作CPU,在修改自己的工作内存变量后,必须立即同步到主存并发出失效指令,同步完成前不得继续执行.其它所以CPU在收到失效指令后必须先应用失效指令,从主存中重新读取后,才能继续往下执行
异步Await转同步,有没有类似?
所以使用写在读前栅栏,就可以保证变更可以立即应用
但同时写在读前是有一定的性能损耗(CPU交互之间的短暂阻塞换取)所以,不是没有代价的