理解并发编程中的重要概念:指令重排序和指令乱序执行

看过了很多介绍指令重排序的文章,可惜由于自己硬件和计算机理论知识缺乏,很难理解深层次的奥秘和实现原理。不过也有很多帖子,讲的浅显易懂,使用的例子很形象。大牛就是能用简单的解释和通俗的比喻,给我们讲明白很高深的东西。这里做个摘抄和总结,和大家分享下,希望大家能够对指令重排序有个形象的认识,不至于在并发编程中犯一些简单的错误。如果理解有错误,希望看到的大神指正。 

从源码变成可以被机器(或虚拟机)识别的程序,至少要经过编译期和运行期。重排序分为两类:编译期重排序运行期重排序(乱序执行),分别对应编译时和运行时环境。由于重排序的存在,指令实际的执行顺序,并不是源码中看到的顺序。


1.编译器的重排序

编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。下面的例子来自并发编程网的文章

“编译期重排序的典型就是通过调整指令顺序,在不改变程序语义的前提下,尽可能减少寄存器的读取、存储次数,充分复用寄存器的存储值。假设第一条指令计算一个值赋给变量A并存放在寄存器中,第二条指令与A无关但需要占用寄存器(假设它将占用A所在的那个寄存器),第三条指令使用A的值且与第二条指令无关。那么如果按照顺序一致性模型,A在第一条指令执行过后被放入寄存器,在第二条指令执行时A不再存在,第三条指令执行时A重新被读入寄存器,而这个过程中,A的值没有发生变化。通常编译器都会交换第二和第三条指令的位置,这样第一条指令结束时A存在于寄存器中,接下来可以直接从寄存器中读取A的值,降低了重复读取的开销。”

另一种编译器优化:在循环中读取变量的时候,为提高存取速度,编译器会先把变量读取到一个寄存器中;以后再取该变量值时,就直接从寄存器中取,不会再从内存中取值了。这样能够减少不必要的访问内存。但是提高效率的同时,也引入了新问题。如果别的线程修改了内存中变量的值,那么由于寄存器中的变量值一直没有发生改变,很有可能会导致循环不能结束。编译器进行代码优化,会提高程序的运行效率,但是也可能导致错误的结果。所以程序员需要防止编译器进行错误的优化。


2.指令间的直接依赖关系

编译器和处理器可能会对操作做重排序,但是要遵守数据依赖关系,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:
名称 代码示例说明
写后读 a = 1;b = a;写一个变量之后,再读这个位置。
写后写 a = 1;a = 2;写一个变量之后,再写这个变量。
读后写 a = b;b = 1;读一个变量之后,再写这个变量。
上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。像这种有直接依赖关系的操作,是不会进行重排序的。特别注意:这里说的依赖关系仅仅是在单个线程内

//写线程
public void writer() {
    a = 1;                   
    flag = true;             
}

//读线程
Public void reader() {
    if (flag) {               
        int i =  a * a;        
    }
}

虽然从读线程和写线程的逻辑关系上来说,写线程不能进行重排序,否则读线程会产生错误的结果;但是实际上写线程还是可能发生重排序的,因为从写线程自身的角度来看,a和flag的写操作完全没有依赖关系,可以随意重排序。编译器没有那么只能,它只能将依赖分析限制在单线程中,不能跨线程进行依赖分析。依赖关系主要是摘抄这篇文章


3.指令间的隐式依赖关系

编译器和CPU都必须保证程序上下文的因果关系不发生改变。因此,在绝大多数情况下,我们写程序都不会去考虑乱序所带来的影响。但是有些程序逻辑,单纯从上下文是看不出它们的因果关系的。比如:
*addr=5; 

val=*data;
从表面上看,addr和data是没有什么联系的,完全可以放心的去乱序执行。但是如果这是在某某设备驱动程序中,这两个变量却可能对应到设备的地址端口和数据端口。并且,这个设备规定了,当你需要读写设备上的某个寄存器时,先将寄存器编号设置到地址端口,然后就可以通过对数据端口的读写而操作到对应的寄存器。那么这么一来,对前面那两条指令的乱序执行就可能造成错误。对于这样的逻辑,我们姑且将其称作隐式因果关系;而指令与指令之间直接的输入输出依赖,也姑且称作显式因果关系。CPU的乱序执行或者编译器的重排序是以保持显式因果关系不变为前提的,但是它们都无法识别隐式因果关系。再举个例子:
obj->data = xxx; 

obj->ready = 1;
当设置了data之后,记下标志,然后在另一个线程中可能执行:
if (obj->ready) 

    do_something(obj->data);
虽然这个代码看上去有些别扭,但是似乎没错。不过,考虑到乱序,如果标志被置位先于data被设置,那么结果很可能就杯具了。因为从字面上看,前面的那两条指令其实并不存在显式的因果关系,乱序是有可能发生的。总的来说,如果程序具有显式的因果关系的话,乱序一定会尊重这些关系;否则,乱序就可能打破程序原有的逻辑。这时候,就需要使用屏障来抑制乱序,以维持程序所期望的逻辑。这段文字摘抄自这篇文章


4.CPU的重排序(指令乱序执行)

现在的CPU一般采用流水线来执行指令。一个指令的执行被分成:取指、译码、访存、执行、写回、等若干个阶段。然后,多条指令可以同时存在于流水线中,同时被执行。指令流水线并不是串行的,并不会因为一个耗时很长的指令在“执行”阶段呆很长时间,而导致后续的指令都卡在“执行”之前的阶段上。相反,流水线是并行的,多个指令可以同时处于同一个阶段,只要CPU内部相应的处理部件未被占满即可。比如说CPU有一个加法器和一个除法器,那么一条加法指令和一条除法指令就可能同时处于“执行”阶段, 而两条加法指令在“执行”阶段就只能串行工作。

然而,这样一来,乱序可能就产生了。比如一条加法指令原本出现在一条除法指令的后面,但是由于除法的执行时间很长,在它执行完之前,加法可能先执行完了。再比如两条访存指令,可能由于第二条指令命中了cache而导致它先于第一条指令完成。一般情况下,指令乱序并不是CPU在执行指令之前刻意去调整顺序。CPU总是顺序的去内存里面取指令,然后将其顺序的放入指令流水线。但是指令执行时的各种条件,指令与指令之间的相互影响,可能导致顺序放入流水线的指令,最终乱序执行完成。这就是所谓的“顺序流入,乱序流出”。


指令流水线除了在资源不足的情况下会卡住之外(如前所述的一个加法器应付两条加法指令的情况),指令之间的相关性也是导致流水线阻塞的重要原因。CPU的乱序执行并不是任意的乱序,而是以保证程序上下文因果关系为前提的。有了这个前提,CPU执行的正确性才有保证。比如:
a++; 

b=f(a); 

c--;
由于b=f(a)这条指令依赖于前一条指令a++的执行结果,所以b=f(a)将在“执行”阶段之前被阻塞,直到a++的执行结果被生成出来;而c--跟前面没有依赖,它可能在b=f(a)之前就能执行完。(注意,这里的f(a)并不代表一个以a为参数的函数调用,而是代表以a为操作数的指令。C语言的函数调用是需要若干条指令才能实现的,情况要更复杂些)。


像这样有依赖关系的指令如果挨得很近,后一条指令必定会因为等待前一条执行的结果,而在流水线中阻塞很久,占用流水线的资源。而编译器的乱序,作为编译优化的一种手段,则试图通过指令重排将这样的两条指令拉开距离, 以至于后一条指令进入CPU的时候,前一条指令结果已经得到了,那么也就不再需要阻塞等待了。比如将指令重排为:
a++; 

c--; 

b=f(a);
相比于CPU的乱序,编译器的乱序才是真正对指令顺序做了调整。但是编译器的乱序也必须保证程序上下文的因果关系不发生改变。


由于重排序和乱序执行的存在,如果在并发编程中,没有做好共享数据的同步,很容易出现各种看似诡异的问题。


参考资料:

并发编程网,程晓明的文章http://ifeve.com/java-memory-model-1/

并发编程网,王晨纯的文章http://ifeve.com/jvm-reordering/

CSDN, 天上满是飞机的博客 http://blog.csdn.net/jiang_bing/article/details/8629425



 

你可能感兴趣的:(乱序执行,指令重排序,编译器和CPU)