Volatile变量
在程式设计中,尤其是在C语言、C++、C#和Java语言中,使用volatile关键字声明的变量或对象通常具有与优化、多线程相关的特殊属性。通常,volatile关键字是用来阻止(伪)编译器因误认某段代码无法被代码本身所改变,而造成的过度优化。如在C语言中,volatile关键字可以用来提醒编译器它后面所定义的变量随时有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有volatile关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。
在C环境中,volatile关键字的真实定义和适用范围经常被误解。虽然C++、C#和Java都保留了C中的volatile关键字,但在这些编程语言中volatile的用法和语义却大相径庭。
C和C++中的volatile
在C,以及C++中,volatile关键字的作用[1]:
- 允许访问内存映射装置
- 允许在
setjmp
和longjmp
之间使用变量 - 允许在信号处理函数中使用sig_atomic_t变量
根据相关的标准(C,C++,POSIX,WIN32)和目前绝大多数实现,对volatile变量的操作并不是原子的,也不能用来为线程建立严格的happens-before关系。volatile
关键字就像便携式线程构建一样基本没什么用处[1][2][3][4][5]。
Visual C++ 2005 保证volatile变量是一种内存屏障,阻止编译器和CPU重新安排读入和写出语义。[6] 在先前版本的Visual C++则没有此类保证。在其他方面将指针定义为volatile可能会影响程序的性能。例如,如果指针定义对代码的其他地方可见,强制编译器将指针视为屏障,就会降低程序的性能,这是完全不必要的。
对用户定义的非基本数据类型使用volatile
基本类型的对象用volatile修饰后,仍旧支持所有的操作(加、乘、赋值等)。但是,用户定义的非基本类型(class、struct、union)的对象被volatile修饰后,具有不同行为:
- 只能调用volatile成员函数;即只能访问它的接口的子集。
- 只能通过const_cast运算符转为没有volatile修饰的普通对象。即由此可以获得对类型接口的完全访问。
- volatile性质会传递给它的数据成员。
volatile与多线程语义
临界区内部,通过互斥锁(mutex)保证只有一个线程可以访问,因此临界区内的变量不需要是volatile的;而在临界区外部,被多个线程访问的变量应为volatile,这也符合了volatile的原意:防止编译器缓存(cache)了被多个线程并发用到的变量。volatile对象只能调用volatile成员函数,这意味着应仅对多线程并发安全的成员函数加volatile修饰,这种volatile成员函数可自由用于多线程并发或者重入而不必使用临界区;非volatile的成员函数意味着单线程环境,只应在临界区内调用。在多线程编程中可以令该数据对象的所有成员函数均为普通的非volatile修饰,从而保证了仅在进入临界区(即获得了互斥锁)后把该对象显式转为普通对象之后才能调用该数据对象的成员函数。这种用法避免了编程者的失误——在临界区以外访问共享对象的内容:
template <typename T> class LockingPtr{
public:
LockingPtr(volatile T& obj, Mutex& mtx)
:pObj_(const_cast<T*>(&obj) ), pMtx_(&mtx)
{ mtx.Lock(); }
~LockingPtr()
{ pMtx->Unlock(); }
T& operator*()
{ return *pObj_; }
T* operator->()
{ return pObj_; }
private:
T* pObj_;
Mutex* pMtx_;
LockingPtr(const LockingPtr&);
LockingPtr& operator=(const LockingPtr&);
}
对于内建类型,不应直接用volatile,而应把它包装为结构的成员,就可以保护了volatile的结构对象不被不受控制地访问。
C语言中MMIO的例子
在这里例子中,代码将foo
的值设置为0
。然后开始不断地轮询它的值直到它变成255
:
static int foo;
void bar(void) {
foo = 0;
while (foo != 255)
;
}
一个执行优化的编译器会提示没有代码能修改foo
的值,并假设它永远都只会是0
.因此编译器将用类似下列的无限循环替换函数体:
void bar_optimized(void) {
foo = 0;
while (true)
;
}
但是,foo可能指向一个随时都能被电脑系统其他部分修改的地址,例如一个连接到中央处理器的装置的硬件寄存器,上面的代码永远检测不到这样的修改。如果不使用volatile关键字,编译器将假设当前程序是系统中唯一能改变这个值部分(这是到目前为止最广泛的一种情况)。 为了阻止编译器像上面那样优化代码,需要使用volatile关键字:
static volatile int foo;
void bar (void) {
foo = 0;
while (foo != 255)
;
}
这样修改以后循环条件就不会被优化掉,当值改变的时候系统将会检测到。
C语言中的优化对比
下面的C程序和后面的汇编代码展示了volatile
关键字如何影响编译器的输出。这里使用的编译器是GCC。
汇编对照 | |
---|---|
不使用volatile | 使用volatile |
#include <stdio.h>
int main() {
int a = 10, b = 100, c = 0, d = 0;
printf("%d", a + b);
a = b;
c = b;
d = b;
printf("%d", c + d);
return 0;
}
|
#include <stdio.h>
int main() {
volatile int a = 10, b = 100, c = 0, d = 0;
printf("%d", a + b);
a = b;
c = b;
d = b;
printf("%d", c + d);
return 0;
}
|
gcc -O3 -S without.c -o without.s | gcc -S with.c -o with.s |
.file "without.c"
.section .rodata.str1.1,"aMS",@progbits,1
.LC0:
.string "%d"
.text
.p2align 4,,15
.globl main
.type main, @function
main:
leal 4(%esp), %ecx
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
movl %esp, %ebp
pushl %ecx
subl $20, %esp
movl $110, 4(%esp)
movl $.LC0, (%esp)
call printf
movl $200, 4(%esp)
movl $.LC0, (%esp)
call printf
addl $20, %esp
xorl %eax, %eax
popl %ecx
popl %ebp
leal -4(%ecx), %esp
ret
.size main, .-main
.ident "GCC: (GNU) 4.2.1 20070719 [FreeBSD]"
|
.file "with.c"
.section .rodata.str1.1,"aMS",@progbits,1
.LC0:
.string "%d"
.text
.p2align 4,,15
.globl main
.type main, @function
main:
leal 4(%esp), %ecx
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
movl %esp, %ebp
pushl %ecx
subl $36, %esp
movl $10, -8(%ebp)
movl $100, -12(%ebp)
movl $0, -16(%ebp)
movl $0, -20(%ebp)
movl -8(%ebp), %edx
movl -12(%ebp), %eax
movl $.LC0, (%esp)
addl %edx, %eax
movl %eax, 4(%esp)
call printf
movl -12(%ebp), %eax
movl %eax, -8(%ebp)
movl -12(%ebp), %eax
movl %eax, -16(%ebp)
movl -12(%ebp), %eax
movl %eax, -20(%ebp)
movl -16(%ebp), %edx
movl -20(%ebp), %eax
movl $.LC0, (%esp)
addl %edx, %eax
movl %eax, 4(%esp)
call printf
addl $36, %esp
xorl %eax, %eax
popl %ecx
popl %ebp
leal -4(%ecx), %esp
ret
.size main, .-main
.ident "GCC: (GNU) 4.2.1 20070719 [FreeBSD]"
|
Java中的volatile
Java也支持 volatile
关键字,但它被用于其他不同的用途。当 volatile
用于一个作用域时,Java保证如下:
- (适用于Java所有版本)读和写一个
volatile
变量有全局的排序。也就是说每个线程访问一个volatile
作用域时会在继续执行之前读取它的当前值,而不是(可能)使用一个缓存的值。(但是并不保证经常读写volatile
作用域时读和写的相对顺序,也就是说通常这并不是有用的线程构建)。 - (适用于Java5及其之后的版本)
volatile
的读和写建立了一个happens-before关系,类似于申请和释放一个互斥锁[7]。
使用volatile
会比使用锁更快,但是在一些情况下它不能工作。volatile
使用范围在Java5中得到了扩展,特别是双重检查锁定现在能够正确工作[8]。
Ada中的volatile
在Ada中,比起关键字,Volatile
标记更像是一种指令。“对于volatile对象而言,所有读和更新都会作为一个整体直接执行到内存”[9]。
参考
- ^ 1.0 1.1 Publication on C++ standards committee website; http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2006/n2016.html (页面存档备份,存于互联网档案馆)
- ^ Volatile Keyword In Visual C++; http://msdn2.microsoft.com/en-us/library/12a04hfd.aspx (页面存档备份,存于互联网档案馆)
- ^ Linux Kernel Documentation - Why the "volatile" type class should not be used; 存档副本. [2007-08-17]. (原始内容存档于2007-08-25).
- ^ Volatile: Almost Useless for Multi-Threaded Programming (Intel Software Network); 存档副本. [2011-08-31]. (原始内容存档于2007-12-13).
- ^ C++ and the Perils of Double-Checked Locking; http://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf (页面存档备份,存于互联网档案馆)
- ^ 存档副本. [2016-02-20]. (原始内容存档于2012-10-20).
- ^ Section 17.4.4: Synchronization Order The Java Language Specification, 3rd Edition. Sun Microsystems. 2005 [2010-11-22]. (原始内容存档于2012-02-14).
- ^ Neil Coffey. Double-checked Locking (DCL) and how to fix it. Javamex. [2009-09-19]. (原始内容存档于2021-03-06).
- ^ "C.6 Shared Variable Control" Ada Reference Manual. ISO. 2005 [2010-05-04]. (原始内容存档于2021-03-06).