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

一、编译器想做什么

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

对于编译器看到的,没有直接关系的不同变量(无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 
#include 
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


你可能感兴趣的:(读书笔记)