并发程序的乱序之一:编译器指令重排

来源:互联网 发布:大大网络用语什么意思 编辑:程序博客网 时间:2024/06/05 01:13

一、编译器想做什么

编译器的优化,希望将整个函数用最少的时钟周期来实现。

对于编译器看到的,没有直接关系的不同变量(无volatile),可以进行乱序的指令调度,而对于相同变量或者有别名或者传播关系的变量,需要按照编译器静态分析的依赖分析结果进行合理调度[注1]。

假设有如下场景:假设该架构下,读取指令从发出到实际读取到数据需要等待2个时钟周期,计算c = b * 3需要一个时钟周期。

{    load a;    load b;    c = b * 3;    use a and c;}
正常执行的顺序如下:
{    load instruction for a (cycle 0);    load instruction for b (cycle 1);    wait for b's loading (cycle 2);    wait for b's loading (cycle 3);    calculate for c using b (cycle 4);    use a and c (cycle 5);}

打乱执行顺序之后:

{    load instruction for b (cycle 0);    load instruction for a (cycle 1); --> padding    (wait for b's loading (cycle 1);)    wait for b's loading (cycle 2);    calculate for c using b (cycle 3);    use a and c (cycle 4);}
可以看出,打乱执行顺序之后,节约了一个时钟周期。

二、对并发的影响:

假设并发时的一种使用场景:假设读写都只有一个线程,写线程中,数据写完时设置标志位。读线程中,通过对flag的判断来对数据进行使用。

编译器进行处理时,data和flag并没有直接的关系(有用户自己指定的隐性关系,但是编译器并不知道),如果编译器进行非常激进的优化,在写线程中,先设置了flag,再写数据,或者在读线程中,将data作为一个不变量,提早进行读取,获取到的data值都是不正确的。

因此,编译器进行的指令重排,会破坏用户代码中存在隐性关系的变量之间的控制流[2]。

write_thread:{    write data;    set flag;}read_thread:{    if (flag) {        use data;    }}

可能的顺序:

write_thread{    set flag;    write data;}read_thread{    tmp = data;    if (flag) {        use tmp;    }}

三、volatile关键字:

volatile关键字会告诉编译器,这是一个易变(不变const,可变mutable)的变量,保证编译器每次对其使用时需要重新载入。同时指示编译器不要对该变量进行激进的优化,对性能会有较大的影响。

volatile场景1:重新载入

#include <assert.h>#include <thread>using namespace std;int flag;void write (){    flag = 1;}void read (){    while (!flag);}int main (){    flag = 0;    thread t1 (write);    thread t2 (read);    t1.join ();     t2.join ();     return 0;}
当开启编译器优化(O1以上),程序将无法获取结果,原因在于read中,开启优化以后,一直访问的是寄存器中保存的flag值,当第一次访问flag且值为0,就进入了L5的无限循环。这种场景,flag应该使用volatile,告诉编译器这是一个易变的值,每次对于flag的访问应该使用内存中的值,而不是使用寄存器中保存的临时值。
    .cfi_startproc    movl    flag(%rip), %eax    testl   %eax, %eax    je  .L5     rep; ret.L5:    jmp .L5     .cfi_endproc
使用volatile后的汇编:当flag为0时,会重新进入循环,循环中重新获取了flag的值。伴随着多次的load(load的流水一般简单计算指令的要长),会导致性能急剧下降,如果不是必须,尽量不要误用。
    .cfi_startproc    .p2align 4,,10    .p2align 3.L4:    movl    flag(%rip), %eax    testl   %eax, %eax    je  .L4     rep; ret    .cfi_endproc

volatile场景2:阻止优化(来自[3]的用例5、6):

int a, b;void foo (){    a = b + 1;    b = 0;}
两个变量都没加volatile时,汇编如下:先读取b的内容,在a还没有计算出结果之前就提前修改了b的值,然后根据第一行读出来的b的值去计算a。

当多线程执行时,如果上下文切换发生在修改b和addl之间,这时候其他线程读取到的就是一个已经置位的b,且尚未计算完成的a。

    .cfi_startproc    movl    b(%rip), %eax //读取b    movl    $0, b(%rip) //修改b    addl    $1, %eax    movl    %eax, a(%rip)    ret     .cfi_endproc
当加上volatile关键字时,汇编如下:此时b的置零发生在a的值计算完成且写回之后,a与b之间的顺序与源码一致。
    .cfi_startproc    movl    b(%rip), %eax    addl    $1, %eax    movl    %eax, a(%rip)    movl    $0, b(%rip)    ret     .cfi_endproc

注1:

相同变量之间存在三种依赖关系[1]:

a = 1; b = a; //先写后读,真依赖b = a; a = 1; //先读后写,反依赖a = 1; a = 2; //写后写,输出依赖

参考资料:

[1] 编译原理 P452

[2] http://blog.csdn.net/beiyetengqing/article/details/49580559

[3] http://hedengcheng.com/?p=725


0 0
原创粉丝点击