volatile、内存屏障、Acquire&Release语义 三者的差别和关系(一) —— 之volatile

前言:
对于这个题目, 本来想写成一篇博客, 但是写下来发现篇幅有点长, 于是拆分成三篇.
volatile 内存屏障 Acquire&Release语义 这三个概念恐怕是做并行编程的时候, 或者说是做C++多线程编程的过程中很容易搞不明白的概念, 下面依据我的知识范围和认识深度, 做一个不算详细但很认真的解释吧, 最后面再再用LevelDb的原子指针类AtomicPointer举个例子. 如果有不对的地方, 希望得到您的指正.

这是三篇博客的第一篇, 首先讲的是volatile这个关键字.
关于volatile可能很多人的感觉是, 见过, 但是不了解. 有人认为volatile可以用来实现原子操作, 实现线程同步, 首先这是错误的.
在http://en.cppreference.com/上面是这么解释的:
volatile object - an object whose type is volatile-qualified, or a subobject of a volatile object, or a mutable subobject of a const-volatile object. Every access (read or write operation, member function call, etc.) on the volatile object is treated as a visible side-effect for the purposes of optimization (that is, within a single thread of execution, volatile accesses cannot be reordered or optimized out. This makes volatile objects suitable for communication with a signal handler, but not with another thread of execution, see std::memory_order)
总结下来可以认为, 在C++中, volatile实现了3个点保证:
( 1 ) 被volatile修饰了的变量的操作不会被编译器优化掉(去除)
( 2 ) 被volatile修饰的变量, 会强制编译器去每次方位这个变量都直接去访问内存对应存储位置(而不是读寄存器或者cpu cache)
( 3 )多个被volatile修饰的变量之间的顺序, 不会被编译器优化调换指令顺序
接下来逐点举例子:

1. volatile修饰了的变量的操作不会被编译器优化掉, 看下面的代码, 看看加了volatile和不加有什么区别:
int a = 1;
void foo() {
  a; 
}
汇编出的结果:
gcc volatile_test.c -O2 -S && cat volatile_test.s
foo:
.LFB0:
        .cfi_startproc
        rep
        ret
        .cfi_endproc
我们可以看到, 编译出来的foo函数里面完全没有a变量的读操作, 也就是说 a; 这行代码被编译器优化掉了, 没了. 

我们再看看加上volatile的情况:
volatile int a = 1;
void foo() {
  a;  
}
编译汇编后:
gcc volatile_test.c -O2 -S && cat volatile_test.s
foo:
.LFB0:
        .cfi_startproc
        movl    a(%rip), %eax //从a的内存位置读取值到eax寄存器
        ret
        .cfi_endproc
我们看到把a变量的值读取到eax寄存器的操作了, 虽然这行代码毫无意义, 但是因为加了volatile, 编译器没有把它优化掉, 这就是volatile保证的第1点.

2. volatile修饰的变量, 会强制编译器去每次方位这个变量都直接去访问内存对应存储位置
在现代操作系统中, 从寄存器里面取一个数要比从内存中取快的多, 所以有时候编译器为优化程序, 就会把一些常用变量放到寄存器中, 下次使用该变量的时候就直接从寄存器中取, 而不再访问内存. 这时, 其他线程把内存中的值改变了, 本线程是不可知晓的. 验证代码如下:
//全局定义
int flag=10;
//线程1
void wait() {
  int count = 1;
  while ( flag != count ) {
    count = ~count;
  }
}
//线程2
void wake() {
  flag = 1;
}
上面的代码, 如果wait函数在wake之前开始运行, 我可以先告诉你, wait函数将不会结束(-O2编译). 因为在线程1, 编译器的优化让wait函数只在一开始把flag的内存值读到寄存器一次, 后面一直用寄存器的值来跟count比较, 不再从内存读取flag的值.  看汇编指令验证:
wait:
.LFB0:
        .cfi_startproc
        movl    flag(%rip), %edx //从内存里读取flag
        cmpl    $1, %edx
        je      .L1
        movl    $1, %eax
        .p2align 4,,10
        .p2align 3
.L3:
        notl    %eax
        cmpl    %edx, %eax   //直接使用edx, 不再去内存里面获取flag的内存值
        jne     .L3
从上面的汇编代码可以看到, flag被读到寄存器edx之后, 当cmp为false则跳到L1结束函数, 为true则跳到L3继续循环.  在这里面我们可以看到, 从第二次cmpl开始汇编指令直接使用edx去做比较, 不再去内存读取flag. 
当我们加上volatile呢? volatile int flag = 10; 汇编变成这样子:
wait:
.LFB0:
        .cfi_startproc
        movl    flag(%rip), %eax
        cmpl    $1, %eax
        je      .L1
        movl    $1, %eax
        .p2align 4,,10
        .p2align 3
.L3:
        movl    flag(%rip), %edx //再次从内存里面把flag读到寄存器edx
        notl    %eax
        cmpl    %eax, %edx
        jne     .L3
汇编结果和没有volatile只有一行的差别, 每次cmpl之前都会重新从内存里面把flag的值读到寄存器edx, 这样, 程序的运行就符合我们的想法了.
完整的测试程序如下(建议实验程序像我这么设计, 如果采用 while(flag==0) 可能这行代码直接被优化掉了, 不能测出volatile的这个第2点保证了
#include 
#include 
#include 
volatile int flag=10; //这里是否由volatile, 结果不同
void* wait(void* param) {
  int count = 1;
  while ( flag != count ) {
    count = ~count;
  }
  printf("wait\n");
}
void* wake(void* param) {
  flag = 1;
  printf("wake\n");
}
int main () {
  pthread_t t[2];
  pthread_create(&t[0], NULL, wait, NULL);
  sleep(1);
  pthread_create(&t[1], NULL, wake, NULL);
  while(1);
}

3. 多个被volatile修饰的变量之间的顺序, 不会被编译器优化调换指令顺序
先看下面这个代码:
int A,B;
void foo() {
  A = B+1;
  B = 5;
}
没有-O2的汇编结果:
movl    B(%rip), %eax
addl    $1, %eax          //先做加法
movl    %eax, A(%rip)
movl    $5, B(%rip)      //再赋值为5
加上-O2的汇编结果 (gcc -O2 -S reorder.c && cat reorder.s)
movl    B(%rip), %eax
movl    $5, B(%rip)  //先赋值为5
addl    $1, %eax      //再执行加法, 顺序调换了
movl    %eax, A(%rip)
从上面的汇编代码可以看到, 汇编指令的执行顺序, 和原来代码的顺序并不一致. 
如何解决这个问题呢? 
volatile保证的第3点: 多个被volatile修饰的变量之间的顺序, 不会被编译器优化调换指令顺序
实验代码如下:
volatile int A,B;
void foo() {
  A = B+1;
  B = 5;
}
汇编结果:
$gcc cordering.c -S -O2 && cat cordering.s
movl    B(%rip), %eax
addl    $1, %eax           //先加1
movl    %eax, A(%rip)
movl    $5, B(%rip)       //赋值为5
到这里可以看到, volatile起作用了. 另外我们会想, 如果只有一个变量加了volatile了呢? 
经过实验验证, 只有一个变量加了volatile, 汇编指令的顺序依然被调换, 实验过程不在这里赘述, 留给读者去尝试.

乱序执行是CPU的一种策略, 是为了让流水线技术上, CPU更充满地被使用
流水线是现代RISC核心的一个重要设计,它极大地提高了性能。
对于一条指令的执行过程,通常分为:取指令、指令译码、取操作数、运算、写结果。前面三步由控制器完成,后面两步由运算器完成。按照传统的做法,当控制器工作的时候运算器在休息,在运算器工作的时候控制器在休息。流水线的做法就是当控制器完成第一条指令的操作后,直接开始开始第二条指令的操作,同时运算器开始第一条指令的操作。这样就形成了流水线系统,这是一条2级流水线。
那么, 可以分析出来, 在加了volatile的4行汇编一共需要4个时钟周期完成, 而没有volatile的4行汇编,可以再3个时钟周期完成( "movl B(%rip), %eax" 和 "movl $5, B(%rip)" 可以同时执行 ).




参考资料:
http://en.cppreference.com/w/cpp/language/cv
http://baiy.cn/doc/cpp/advanced_topic_about_multicore_and_threading.htm


转发请注明出处: http://blog.csdn.net/answer3y/article/details/21476787



你可能感兴趣的:(C++)