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

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

这是三篇博客的第一篇, 首先讲的是volatile这个关键字.
关于volatile可能很多人的感觉是, 见过, 但是不了解. 有人认为volatile可以用来实现原子操作, 实现线程同步, 首先这是错误的.
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和不加有什么区别:
[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. int a = 1;  
  2. void foo() {  
  3.   a;   
  4. }  
汇编出的结果:
[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. gcc volatile_test.c -O2 -S && cat volatile_test.s  
  2. foo:  
  3. .LFB0:  
  4.         .cfi_startproc  
  5.         rep  
  6.         ret  
  7.         .cfi_endproc  
我们可以看到, 编译出来的foo函数里面完全没有a变量的读操作, 也就是说 a; 这行代码被编译器优化掉了, 没了. 

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

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

3. 多个被volatile修饰的变量之间的顺序, 不会被编译器优化调换指令顺序
先看下面这个代码:
[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. int A,B;  
  2. void foo() {  
  3.   A = B+1;  
  4.   B = 5;  
  5. }  
没有-O2的汇编结果:
[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. movl    B(%rip), %eax  
  2. addl    $1, %eax          //先做加法   
  3. movl    %eax, A(%rip)  
  4. movl    $5, B(%rip)      //再赋值为5  
加上-O2的汇编结果 (gcc -O2 -S reorder.c && cat reorder.s)
[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. movl    B(%rip), %eax  
  2. movl    $5, B(%rip)  //先赋值为5   
  3. addl    $1, %eax      //再执行加法, 顺序调换了  
  4. movl    %eax, A(%rip)  
从上面的汇编代码可以看到, 汇编指令的执行顺序, 和原来代码的顺序并不一致. 
如何解决这个问题呢? 
volatile保证的第3点: 多个被volatile修饰的变量之间的顺序, 不会被编译器优化调换指令顺序
实验代码如下:
[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. volatile int A,B;  
  2. void foo() {  
  3.   A = B+1;  
  4.   B = 5;  
  5. }  
汇编结果:
[cpp] view plaincopyprint?在CODE上查看代码片派生到我的代码片
  1. $gcc cordering.c -S -O2 && cat cordering.s  
  2. movl    B(%rip), %eax  
  3. addl    $1, %eax           //先加1  
  4. movl    %eax, A(%rip)  
  5. 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)" 可以同时执行 ).




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


转发请注明出处: http://blog.csdn.net/answer3y/article/details/21476787
0 0
原创粉丝点击