04 如何解决指令重排序——内存屏障指令
Diego38 96 1

1. 前言

上节讲到由于编译器和处理器的指令重排序带来共享变量在多线程中不可见问题。

那有没有方法告诉编译器和处理器让它们在一定场景下不要做指令重排呢?就是在 Java 编译器生成指令序列的适当的位置插入内存屏障指令,这样做能禁止编译器和处理器的指令排序。

本节我们就学习什么是内存屏障?有哪些内存屏障指令,内存屏障是如何禁止指令重排序的。

2. 内存屏障

保证可见性的手段是防止指令重排,防止指令重排要靠内存屏障。

内存屏障(英语:Memory barrier),也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,它使得 CPU 或编译器在对内存进行操作的时候, 严格按照一定的顺序来执行, 也就是说在 memory barrier 之前的指令和 memory barrier 之后的指令不会由于系统优化等原因而导致乱序。

屏障指令的统称叫做内存屏障,也叫内存栅栏, 就好比将在指令与指令边界驻扎栅栏,一方的指令无法跨越栅栏来到另一方。 内存屏障可以禁止指令重排序,内存屏障之前的写操作时,强制刷入内存;内存屏障之后的读操作可以读取之前的写操作的值,进而实现可见性。

2.1 基本指令

在了解内存屏障指令之前,我们要先了解机器指令 (机器指令是 CPU 能直接识别并执行的指令) 中两个内存访问的基本指令

  • Store:写指令,将处理器的缓存数据刷入内存
  • Load:读指令,将内存数据拷贝到处理器缓存当中
void bar() {
    a = 1; //1. store指令操作变量a
    b = a + 1; //2. 先load变量a,再使用store指令对变量b进行store

  }

void foo() {
    print(b);//3. load指令操作变量b
}

我们看上面的代码, 处理器会解释成相应的 load 和 store 指令, 三条语句都是可能发生指令重排序的, 导致 foo 函数输出的结果有可能是 0,1 或 2。 假设线程 A 执行 bar,线程 B 执行 foo,那么就会有三种可能的执行过程

  1. 当线程 B 先执行,线程 A 后执行,B 线程会读到变量的初始化值 0
  2. 当线程 A 执行 bar 时 1 和 2 操作发生了指令重排,2 操作先于 1 执行,线程 B 这时读到 b 变量为 1
  3. 当线程 A 执行 bar 时 1 和 2 操作未发生指令重排,线程 A 线程执行结束,线程 B 执行 foo,读到 b 正常值为 2

其中第二种执行过程发生了指令重排序,而我们接下来介绍的内存屏障指令具体是如何干预重排序的;

2.2 内存屏障指令分类

image

如果把内存屏障指令比作地铁闸机口,那么 Load 和 Store 指令就是排队进入的行人,闸机口只能保证一个方向进出,跨过闸机口的行人 (Load 或 Store 指令) 不能反向穿过闸机口。

内存屏障指令分为四类:

  • LoadLoad : 确保 LoadLoad 指令之前的 Load 指令的执行,先于 LoadLoad 指令之后的 Load 指令及其后续的 load 指令。(Load1 -> LoadLoad -> Load2)
  • StoreStore: 确保 StoreStore 指令之前的 Store 指令的执行,先于 StoreStore 指令之后的 Store 指令及其后续的 Store 指令。(Store1 -> StoreStore -> Store2)
  • LoadStore:确保 LoadStore 指令之前的 Load 指令的执行,先于 LoadStore 指令之后的 Store 指令及其后续的 Store 指令。(Load1 -> LoadStore -> Store2)
  • StoreLoad:确保 StoreLoad 指令之前的所有内存指令 (load,store) 执行,先于 StoreLoad 指令之后的所有内存指令。(Store1 -> StoreLoad -> Load2)

注意:最后一个 StoreLoad 指令兼具其他三指令的效果,并且 StoreLoad 指令会使之前所有内存访问指令执行完成,才会执行 StoreLoad 之后的内存访问指令。我们下面看几个例子,看下内存屏障指令在代码里怎么使用的,并思考一个问题,内存屏障指令的使用是否多多益善?

2.3 内存屏障指令使用

我们看下面一段伪代码:

void foo() {        
         a=1;
         //此处插入StoreStore指令,禁止a=1与b=1重排序
         b=1; 
}

void bar() { 
        while (b == 0)   continue;
       //此处插入LoadLoad指令,禁止b==0与a==1重排序
        assert(a == 1);
} 

在未插入任何内存屏障指令之前,foo 函数的 b=1 有可能在 a=1 之前执行,导致 bar 函数执行 assert(a == 1)时断言失败 (a==0)。

按照上述代码插入内存屏障以后,结合 2.2 小节的对指令的说明,foo 的两条指令不会发生指令重排序,bar 的两条指令也不会发生指令重排,代码按照编写的顺序执行,bar 函数能收到预期的结果即 assert (a==1) 断言成功。

不同的处理器如果发现内存屏障指令不会对当前执行顺序有影响,会自动省略掉,比如 X86 处理器不会对 StoreStore 操作做重排序,所以插入任何 StoreStore 屏障指令会被自动省略。

我们之前提到过之所以发生指令重排序,是由于为了提升执行效率,CPU 做了优化。而内存屏障为了保证正确性会打破原来的优化,所以插入内存屏障指令是会带来一定的开销的,所以我们要遵守一个原则,必要时再插入内存屏障指令。

3. 总结

本节对内存屏障指令的分类和使用做了一个解读, 在后续的章节我们会学习到 final 和 volatile 关键字是如何将内存屏障指令插入到机器指令中的。 image

参考资料

  1. 维基百科
预览图
评论区

索引目录