zoukankan      html  css  js  c++  java
  • 编写安全代码:小心volatile的原子性误解

    本文的copyleft归gfree.wind@gmail.com所有,使用GPL发布,可以自由拷贝,转载。但转载请保持文档的完整性,注明原作者及原链接,严禁用于任何商业用途。
    ======================================================================================================
    关于volatile的说明,这是一个老生常谈的问题。volatile的定义很简单,将其理解为易变的,防止编译器对其进行优化。那么其用途一般有以下三种:
    1. 外部设备寄存器映射后的内存——因为外部设备寄存器可能会由于外部设备的状态变化而改变,因此映射后的内存需要注明为volatile的;
    2. 多线程或异步访问的全局变量;
    3. 嵌入式汇编——防止编译器对其优化;
     
    这三种用途中的第一种和第三种一般都没有什么疑问。但是关于第二个用途,经常有朋友对其有误解。
     
     
    首先说一下,什么时候情况下全局变量需要有volatile修饰呢?比如,我们大部分的全局变量都是用锁保护的,那么是否还需要volatile呢?答案是否定的,即当全局变量由锁保护的时候,该全局变量是不需要volatile的。原因有二:一是锁保证了临界区的串行;二是锁的实现中有内存屏障,保证临界区访问全局变量为最新值。那么,就可以总结出,当没有锁保护的全局变量是需要使用volatile修饰的,如以下这种情况:
    1. 全局变量int exit_flag = 0;
    2. 线程1的主循环的退出条件是检查exit_flag是否为1,为1,则退出主循环;
    3. 线程2在某些情况下,修改exit_flag为1。
    另外如果是异步情况,即没有线程2,而有一个信号处理函数,在收到指定信号的情况下将exit_flag赋值为1。
    这时,exit_flag都需要使用volatile来修饰。不然对于线程1的代码,如果编译器发现在线程1的代码中没有任何地方修改exit_flag,有可能会将exit_flag放入寄存器缓存。这样,在每次的条件检查的时候,都是从寄存器中读取,而非exit_flag对应的内存。这样将导致每次读取的值都为0,从而导致thread1无法退出。而使用volatile修饰exit_flag,会避免编译器做这种优化,强制每次读取都是从内存中读取,这样就可以保证了在exit_flag置为1时,thread1可以读取到最新值而退出。
     
    那么现在有这样的一个问题。当有一个全局变量volatile int counter = 0作为一个计数器,而且同时有两个线程会修改这个计数器,这时这个counter是否需要锁的保护呢?
    比如下面的代码:
    1. static volatile int counter = 0;
    2. void add_counter(void)
    3. {
    4.     ++counter;
    5. }
    在多线程的环境下,是否安全呢?counter的递增是否为我们所期待的结果呢?请大家先想一分钟!
     
    为了得出答案,让我们看汇编代码:

    1. add_counter:
    2. pushl %ebp
    3. movl %esp, %ebp
    4. movl counter, %eax
    5. addl $1, %eax
    6. movl %eax, counter
    7. popl %ebp
    8. ret
    看红色的这三行。怎么回事?为什么还是先把counter存入了寄存器eax,然后对eax进行操作,再将eax存入counter。这样的话,++counter就绝不会是原子性操作!必须由锁保护!
     
    下面看看为什么汇编是这样的行为。究竟volatile提供了什么样的服务?!
    首先修改一下之前的C代码:

    1. static int counter = 0;
    2. void add_counter(void)
    3. {
    4.     for (; counter != 0x10; ++counter) {
    5.         ++counter;
    6.     }
    7. }
    然后看其汇编代码,记得要是使用-O优化啊。
    [fgao@fgao-vm-fc13 test]$ gcc -S -O test.c

    1. add_counter:
    2. pushl %ebp
    3. movl %esp, %ebp
    4. movl counter, %eax
    5. cmpl $16, %eax
    6. je .L4
    7. .L5:
    8. addl $2, %eax
    9. cmpl $16, %eax
    10. jne .L5
    11. movl %eax, counter
    12. .L4:
    13. popl %ebp
    14. ret
    从上面的汇编代码中,可以很清楚的看出:这时将counter的值存入eax,然后每次给eax加2,然后使用eax与16比较,来完成C代码中的for循环的工作。
     
    下面给counter加上volatile修饰符

    1. static volatile int counter = 0;
    2. void add_counter(void)
    3. {
    4.     for (; counter != 0x10; ++counter) {
    5.         ++counter;
    6.     }
    7. }
    依然打开编译器的优化开关
    [fgao@fgao-vm-fc13 test]$ gcc -S -O test.c

    1. add_counter:
    2. pushl %ebp
    3. movl %esp, %ebp
    4. movl counter, %eax
    5. cmpl $16, %eax
    6. je .L4
    7. .L5:
    8. movl counter, %eax
    9. addl $1, %eax
    10. movl %eax, counter
    11. movl counter, %eax
    12. addl $1, %eax
    13. movl %eax, counter
    14. movl counter, %eax
    15. cmpl $16, %eax
    16. jne .L5
    17. .L4:
    18. popl %ebp
    19. ret
    L5为对应C代码中的for循环的汇编实现。通过对比前一个汇编,我们很容易就发现了区别。虽然在执行counter递增的时候,两者都使用了寄存器eax。但是在每次访问counter的时候,前者会直接使用寄存器eax,而后者(使用volatile的时候)会重新从counter中读取最新值至eax,然后再使用eax。
     
    OK。现在volatile的情况已经明了。volatile只提供了保证访问该变量时,每次都是从内存中读取最新值,并不会使用寄存器缓存该值——每次都会从内存中读取。而对该变量的修改,volatile并不提供原子性的保证。那么编译器究竟是直接修改内存的值,还是使用寄存器修改都符合volatile的定义。所以,一句话,volatile并不提供原子性的保证。
  • 相关阅读:
    聊聊WS-Federation
    用双十一的故事串起碎片的网络协议(上)
    责任链模式的使用-Netty ChannelPipeline和Mina IoFilterChain分析
    最小化局部边际的合并聚类算法(中篇)
    最小化局部边际的合并聚类算法(上篇)
    UVaLive 7371 Triangle (水题,判矩形)
    UVaLive 7372 Excellence (水题,贪心)
    POJ 3312 Mahershalalhashbaz, Nebuchadnezzar, and Billy Bob Benjamin Go to the Regionals (水题,贪心)
    UVa 1252 Twenty Questions (状压DP+记忆化搜索)
    UVa 10817 Headmaster's Headache (状压DP+记忆化搜索)
  • 原文地址:https://www.cnblogs.com/biglucky/p/5499384.html
Copyright © 2011-2022 走看看