6

并发理论基础:指令重排序问题

 2 years ago
source link: https://www.techstack.tech/post/164942290/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

并发理论基础:指令重排序问题

发表于2022-04-08|更新于2022-04-08|并发编程
字数总计:2.4k|阅读时长:8分钟|阅读量:

什么是指令的重排序

我们平时所讲的指令重排很容易被当成动词去理解,其实正确的理解是当成名词,即指令重排现象。 简单来说就是: 在程序中写的代码,在执行时并不一定按照写的顺序。

public class PossibleReordering {
static int x = 0, y = 0;
static int a = 0, b = 0;

public static void main(String[] args) throws InterruptedException {
Thread one = new Thread(new Runnable() {
public void run() {
a = 1;
x = b;
}
});

Thread other = new Thread(new Runnable() {
public void run() {
b = 1;
y = a;
}
});
one.start();other.start();
one.join();other.join();
System.out.println(“(” + x + “,” + y + “)”);
}

很容易想到这段代码的运行结果可能为(1,0)、(0,1)或(1,1),因为线程one可以在线程two开始之前就执行完了,也有可能反之,甚至有可能二者的指令是同时或交替执行的。然而,这段代码的执行结果也可能是(0,0). 因为,在实际运行时,代码指令可能并不是严格按照代码语句顺序执行的。得到(0,0)结果的语句执行过程,如下图所示。值得注意的是,a=1和x=b这两个语句的赋值操作的顺序被颠倒了,或者说,发生了指令“重排序”(reordering)。事实上,输出了这一结果,并不代表一定发生了指令重排序,内存可见性问题也会导致这样的输出。

指令重排的场景

编译器重排序

以 Java 语言为例,Java既可以作为解释型语言去用,也可以作为编译型语言。但是主流的做法是当成编译型语言在用。这里先解释下编译期:像c/c只有一个编译期,就是调用gcc命令将c/c代码编译成汇编代码。但是Java中有两个编译期:

  1. 调用javac命令将Java代码编译成Java字节码;
  2. Unix派系平台上调用gcc命令将openjdk源码编译成汇编代码。

编译期间,Java中所谓的指令重排主要是说编译openjdk时的指令重排,将Java代码编译成Java字节码是没有做指令重排的。即加不加volatile,生成的字节码文件是一样的。

代码如下:

public class Complier {
public volatile int found = 0;
public int a = 0;

public void change() {
found = 1;
a = 1;
}
}

不加 volatile 时字节码文件:

public void change();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: iconst_1
2: putfield #2 // Field found:I
5: aload_0
6: iconst_1
7: putfield #3 // Field a:I
10: return
LineNumberTable:
line 12: 0
line 13: 5
line 14: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Ltech/stack/moka/Complier;

添加上 volatile 时字节码文件:

public void change();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: iconst_1
2: putfield #2 // Field found:I
5: aload_0
6: iconst_1
7: putfield #3 // Field a:I
10: return
LineNumberTable:
line 12: 0
line 13: 5
line 14: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Ltech/stack/moka/Complier;

通过对比我们可以发现无论是否添加 volatile 关键字 change() 方法字节码文件都没有发生变化。JVM在运行的时候就是通过字段属性中的Access flags属性来判断操作的类属性有没有加volatile修饰。在Java 中指令重排是编译器优化中的一种,编译openjdk是启用了O2级编译器优化。进行了比如优化无效代码、编译期完成简单运算、处理编译期屏障等等。

指令集并行的重排序

这个是针对于CPU指令级别来说的,为了使处理器内部的运算单元能尽量被充分利用,处理器采用了指令集并行技术来讲多条指令重叠执行,如果不存在数据依赖性,处理器可以改变主句对应的机器指令执行顺序,对输入的代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,并确保这一结果和顺序执行结果是一致的,但是这个过程并不保证各个语句计算的先后顺序和输入代码中的顺序一致。在计算机工程领域中又叫乱序执行(错序执行,英语:out-of-order execution,简称OoOEOOE)是一种应用在高性能微处理器中来利用指令周期以避免特定类型的延迟消耗的范式。在这种范式中,处理器在一个由输入数据可用性所决定的顺序中执行指令,而不是由程序的原始数据所决定。在这种方式下,可以避免因为获取下一条程序指令所引起的处理器等待,取而代之的处理下一条可以立即执行的指令。主要目的还是为了使处理器内部的运算单元能尽量被充分利用。对于处理器为什么要进行指令重排或者怎样进行执行重排具体的可以了解处理器中的流水线技术

内存重排序

因为CPU缓存使用 缓冲区的方式(Store Buffer )进行延迟写入,这个过程会造成多个CPU缓存可见性的问题,这种可见性的问题导致结果的对于指令的先后执行显示不一致,从表面结果上来看好像指令的顺序被改变了,内存重排序现象其实是造成可见性问题的主要原因所在,其原理可在并发理论基础:缓存可见性、MESI、内存屏障可中详细了解。

文章开始展示的指令重排现象很有可能是由于内存缓存可见性原因导致的。

指令重排序的原则(as-if-serial语义)

编译器和处理指令也并非什么场景都会进行指令重排序的优化,而是会遵循一定的原则,只有在它们认为重排序后不会对程序结果产生影响的时候才会进行重排序的优化,如果重排序会改变程序的结果,那这样的性能优化显然是没有意义的。而遵守as-if-serial 语义规则就是重排序的一个原则,as-if-serial 的意思是说,可以允许编译器和处理器进行重排序,但是有一个条件,就是不管怎么重排序都不能改变单线程执行程序的结果。

单线程重排序

比如下面这段代码来说,语句2和语句1、3之间没有任何依赖关系,而语句1和语句3却有着明确的依赖关系,遇到这样的语句(换成指令也一样)编译器就认为先执行语句2再执行语句1、3对程序结果是没有任何影响的,所以可以对语句2进行重排序,反之编译器不会对语句3重排序到语句1之前,因为语句3和语句1是有数据依赖关系的,如果对3进行重排序就有可能影响到最终的程序运行结果,这也就是as-if-serial语义所表达的,只要程序结果不会改变,那么就算我重排序了代码和指令,但从结果上来看我好像就是完全串行按顺序的把代码从头执行到尾。

a=1;  //1
b=2; //2
c=a+1; //3

编译器优化后可能执行顺序如下

b=2;   //2
a=1; //1
c=a+1; //3

重排序对多线程的影响

单线程的重排序很简单,因为可以通过语义分析就能知道前后代码的依赖性,但是多线程就不一样了,多线程环境里编译器和CPU指令优化根本无法识别多个线程之间存在的数据依赖性,比如说下面的程序代码如果两个方法在l两个不同的线程里面调用就可能出现问题。

就如文章开头展示的代码片段,由于两个线程中的代码没有依赖关系,编译期或者 CPU 很有可能对代码指令进行重新排序。从而造成了在多线程中的错误的现象。

禁止重排序

在复杂的多线程环境下,编译器和处理器是根本无法通过语义分析来知道代码指令的依赖关系的,所以这个问题只交给能写代码的人才能清楚的知道,这个时候编写代码的人就需要通过一种方式显示的告诉编译器和处理器哪些地方是存在逻辑依赖的,这些地方不能进行重排序。

所以在编译器层面 和CPU层面都提供了一套内存屏障来禁止重排序的指令,不过在Java中为了简化开发人员的工作,避免开发人员需要对底层的系统原理的深度理解,所以封装了一套规范,把这些复杂的指令操作与开发人员隔离开来,这就是我们常说的Java 内存模型(JMM),JMM定义了几个happens before原则来指导并发程序编写的正确性。程序员可以通过Volatile、synchronized、final几个关键字告诉编译器和处理器哪些地方是不允许进行重排序的。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK