内存屏障(Memory Barrier)

简介

是cpu指令

作用

  • 保证指令执行的顺序,内存屏障前的指令一定先于内存屏障后的指令
  • 将write buffer的缓存行,立即刷新到内存中

重排列

内存屏障保证指令的顺序?因为cpu和编译器会进行优化而导致指令重排列,单线程情况下,没什么影响,而多线程时,会发生与我们代码执行顺序不一样的结果。

Q:
CPU为何要重排序内存访问指令?在哪种场景下会触发重排序?

CPU为何要重排序内存访问指令?

处理器通过缓存(caching)能够从数量级上降低内存延迟的成本这些缓存为了性能重新排列待定内存操作的顺序。

现代CPU的主频越来越高,与cache的交互次数也越来越多。当CPU的计算速度远远超过访问cache时,会产生cache wait,过多的cache
解决:cache分片 ,将cache分成若干个互不相干联的slots(memeory bank),CPU可以自行选择在多个 闲置的bank 中进行存取

下面摘自网络上的一个例子:


内存屏障(Memory Barrier)_第1张图片
image.png

一台双cpu的计算机,cache按地址划分了bank0、bank1
理想的内存访问指令顺序:

  1. CPU0往?cache address 0×12345000 写入一个数字 1。因为address 0×12345000是偶数,所以值被写入 bank0.
  2. CPU1读取 bank0 address 0×12345000 的值,即数字1。
  3. CPU0往 cache 地址 0×12345100 ?写入一个数字 2。因为address 0×12345100是奇数,所以值被写入 bank1.
  4. CPU1读取 bank1 address ?0×12345100 的值,即数字2。

重排序后的内存访问指令顺序:

  1. CPU0 准备往 bank0 address 0×12345000 写入数字 1。
  2. CPU0检查 bank0 的可用性。发现 bank0 处于 busy 状态。
  3. CPU0 为了防止 cache等待,发挥最大效能,将内存访问指令重排序。即先执行后面的 bank1 address 0×12345100 数字2的写入请求。
  4. CPU0检查 bank1 可用性,发现bank1处于 idle 状态。
  5. CPU0 将数字2写入 bank 1 address 0×12345100。
  6. CPU1来读取 ?0×12345000,未读到 数字1,出错。
  7. CPU0 继续检查 bank0 的可用性,发现这次?bank0 可用了,然后将数字1写入 0×12345000。
  8. CPU1 读取 0×12345100,读到数字2,正确。

第 3 步发生了指令重排序,并导致第 6步读到错误的数据。
内存屏障是用来防止CPU出现指令重排序的利器之一。

内存屏障与volatile

mov    0xc(%r10),%r8d ; Load
inc    %r8d           ; Increment
mov    %r8d,0xc(%r10) ; Store
lock addl $0x0,(%rsp) ; StoreLoad Barrier

volatile作用:(#lock前缀)

  1. 禁止该指令与之前和之后的读和写指令重排序。
  2. 把写缓冲区中的所有数据刷新到内存中。
  3. 使其他处理器里的缓存行无效

进行写入操作时,会在写后面加上一条store指令,将本地内存中的共享变量值立即刷新到主存。
在进行读操作时,会在读前面加上一条load指令,从主存中读取变量。

volatile不保证原子性

上述场景中最后一步,StoreLoad Barrier保证让所有的CPU内核都获得了最新的值,但是在load到store之间,要是有其他cpu也修改了值,最后StoreLoad Barrier会使修改丢失。

不保证原子性的示例

1.单例模式

public class VolatileTest {
    private static volatile VolatileTest instance = null;

    private VolatileTest() {
    }

    public static VolatileTest getInstance() {
        if (instance == null) {
            synchronized (VolatileTest.class) {
                if (instance == null) {
                    System.out.println("init!");
                    instance= new VolatileTest();
                }
            }
        }
        return instance;
    }

instance= new VolatileTest()过程:

  1. 为对象分配内存;
  2. 调用对应的构造做对象的初始化操作;(构造函数)
  3. 将引用instance 指向新分配的空间。
    非原子操作!!!
    在单线程中,优化重排序,2与3的位置可能会相反,在没有进行初始化操作的时候,就将引用指向了未初始化的内存空间,是没问题的

在多线程的情况下:

线程1:getInstance()线程1:判断INSTANCE是否为空?Y
线程1:获取同步锁
线程1:判断INSTANCE是否为空? Y
线程1:为新对象分配内存
线程1:将引用INSTANCE指向新分配的空间。
线程2:getInstance()
线程2:判断INSTANCE是否为空? N线程2:返回INSTANCE对象 (擦。INSTANCE表示老子还没被初始化呢)
线程2:使用INSTANCE对象时发现这货不能用,bug found!
线程1:调用对应构造器作对象初始化操作。

作者:conndots
链接:https://www.zhihu.com/question/35268028/answer/62025044
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

双重校验锁:

  1. 需要加上volatile防止重排序出错
  2. synchronized 保证非原子性不出错

2. long double

在64位上无问题,在32位计算机上,对于long double 64位读取,需要进行两次处理,非原子性操作。
对volatile变量的单次读/写操作可以保证原子性的,如long和double类型变量

  1. synchronized锁在加锁前和释放锁时都会sync CPU的缓存和主存中数据,保持一致,所以不存在可见性问题;
  2. synchronized保证存在竞态条件的临界区代码执行的互斥,即便线程内部重排序,但是重排序不影响执行结果,对于另一个线程来说看到的都是相同的终态,所以也不会有重排序问题;
  3. 非64位stop,而且本身的读写不存在竞态了,不会出现更新一般被另一个线程看到中间态的情况,所以不需要考虑原子性问题。

作者:wzgg
链接:https://zhuanlan.zhihu.com/p/34379489
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

参考

从JVM并发看CPU内存指令重排序(Memory Reordering)

你可能感兴趣的:(内存屏障(Memory Barrier))