您的位置:首页 > 编程语言

编写安全代码:小心volatile的原子性误解

2014-02-27 22:11 211 查看
作者:gfree.wind@gmail.com

博客:blog.focus-linux.net linuxfocus.blog.chinaunix.net

本文的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是否需要锁的保护呢?
比如下面的代码:

static volatile int counter
= 0;

void add_counter(void)

{

++counter;

}

在多线程的环境下,是否安全呢?counter的递增是否为我们所期待的结果呢?请大家先想一分钟!

为了得出答案,让我们看汇编代码:

add_counter:
pushl %ebp
movl %esp, %ebp
movl counter, %eax
addl $1, %eax
movl %eax, counter
popl %ebp
ret

看红色的这三行。怎么回事?为什么还是先把counter存入了寄存器eax,然后对eax进行操作,再将eax存入counter。这样的话,++counter就绝不会是原子性操作!必须由锁保护!

下面看看为什么汇编是这样的行为。究竟volatile提供了什么样的服务?!
首先修改一下之前的C代码:

static int counter
= 0;

void add_counter(void)

{

for (; counter
!= 0x10;
++counter)
{

++counter;

}

}

然后看其汇编代码,记得要是使用-O优化啊。
[fgao@fgao-vm-fc13 test]$ gcc -S -O test.c

add_counter:
pushl %ebp
movl %esp, %ebp
movl counter, %eax
cmpl $16, %eax
je .L4
.L5:
addl $2, %eax
cmpl $16, %eax
jne .L5
movl %eax, counter
.L4:
popl %ebp
ret

从上面的汇编代码中,可以很清楚的看出:这时将counter的值存入eax,然后每次给eax加2,然后使用eax与16比较,来完成C代码中的for循环的工作。

下面给counter加上volatile修饰符

static volatile int counter
= 0;

void add_counter(void)

{

for (; counter
!= 0x10;
++counter)
{

++counter;

}

}

依然打开编译器的优化开关
[fgao@fgao-vm-fc13 test]$ gcc -S -O test.c

add_counter:
pushl %ebp
movl %esp, %ebp
movl counter, %eax
cmpl $16, %eax
je .L4
.L5:
movl counter, %eax
addl $1, %eax
movl %eax, counter
movl counter, %eax
addl $1, %eax
movl %eax, counter
movl counter, %eax
cmpl $16, %eax
jne .L5
.L4:
popl %ebp
ret

L5为对应C代码中的for循环的汇编实现。通过对比前一个汇编,我们很容易就发现了区别。虽然在执行counter递增的时候,两者都使用了寄存器eax。但是在每次访问counter的时候,前者会直接使用寄存器eax,而后者(使用volatile的时候)会重新从counter中读取最新值至eax,然后再使用eax。

OK。现在volatile的情况已经明了。volatile只提供了保证访问该变量时,每次都是从内存中读取最新值,并不会使用寄存器缓存该值——每次都会从内存中读取。而对该变量的修改,volatile并不提供原子性的保证。那么编译器究竟是直接修改内存的值,还是使用寄存器修改都符合volatile的定义。所以,一句话,volatile并不提供原子性的保证。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: