语言基础(7):关键字volatile
volatile,词典上的解释为:易失的;易变的;易挥发的。C++使用关键词volatile,有三个特性:易变性、不可优化性、顺序性。下文将详细分析,先看下 BS 在 "The C++ Programming Language" 对 volatile 修饰词的说明:
"A volatile specifier is a hint to a compiler that an object may change its value in ways not specified by the language so that aggressive optimizations must be avoided."
直白的翻译就是:“volatile修饰符是面向编译器的提示,修饰的对象可以通过语言不指定的方式改变其值(例如端口数据),因此对其进行激进的优化必须避免。”
PS:有个隐藏的点,volatile是面向编译器的关键字,因此它并不能约束CPU的指令优化!
1、volatile的起源
C/C++的Volatile关键词,有三个特性:易变性;不可优化性;顺序性。那么,为什么Volatile被设计成这样呢?要回答这个问题,就需要从volatile关键词的产生说起。(参考自C++ and the Perils of Double-Checked Locking第10章节:volatile:A Brief History。这是一篇顶顶好的论文,值得多次阅读,强烈推荐!)
volatile关键词,最早出现于19世纪70年代,被用于处理memory-mapeed I/O (MMIO)带来的问题。在引入MMIO之后,一块内存地址,既有可能是真正的内存,也有可能被映射到一个I/O端口。相对的,读写一个内存地址,既有可能操作内存,也有可能读写的是一个I/O设备。MMIO为什么需要引入volatile关键词?考虑如下的一个代码片段:
unsigned int *p = GetMagicAddress();
unsigned int a,b;
a = *p; // (1)
b = *p; // (2)
*p = a; // (3)
*p = b; // (4)
在此代码片段中,指针p既有可能指向一个内存地址,也有可能指向一个I/O设备。如果指针p指向的是I/O设备,那么:
- 代码(1),(2)中的a,b,就会接收到I/O设备的连续两个字节(如果 p 指向内存,此时,编译器的优化策略,就可能会判断出a,b同时从同一内存地址读取数据,在做完(1)之后,直接将a赋值给b),对于I/O设备,需要防止编译器做这个优化,不能假设指针b指向的内容不变——易变性;
- 代码(3),(4)也有类似的问题,编译器发现将a,b同时赋值给指针p是无意义的,因此可能会优化代码(3)中的赋值操作,仅仅保留代码(4)。对于I/O设备,需要防止编译器将写操作给彻底优化消失了——”不可优化”性。
- 对于(1), (2)或者(3), (4),编译器不能随意交互指令的顺序,因为顺序一变,写入I/O设备的内容也就发生变化了——”顺序性”。
基于MMIO的这三个需求,设计出来的C/C++ Volatile关键词,所含有的特性,也就是本文前面分析的三个特性:易变性;不可优化性;顺序性。
2、volatile的特性
前面说过volatile有三个特性,下面我们通过代码来分别分析
1. 易变性
// 非volatile变量
b = a + 1;这条语句,对应的汇编指令是:lea ecx, [eax + 1]。由于变量a在前一条语句a = fn(c)执行时,被缓存在了寄存器eax中,因此b = a + 1;语句,可以直接使用仍旧在寄存器eax中的a,来进行计算,对应的也就是汇编:[eax + 1]。
// volatile变量
变量a被设置为volatile属性,汇编代码上带来很大的变化,a = fn(c)执行后,寄存器ecx中的a,被写回内存:mov dword ptr [esp+0Ch], ecx。然后,在执行b = a + 1;语句时,变量a有重新被从内存中读取出来:mov eax, dword ptr [esp + 0Ch],而不再直接使用寄存器ecx中的内容。
结论:C/C++ 关键字volatile的易变性,在汇编层面反映出来,就是两条语句,下一条语句不会直接使用上一条语句对应的volatile变量的寄存器内容,而是重新从内存中读取。
2. 不可优化性
// 非volatile变量
非volatile变量a,b,c全部被编译器优化掉了 (optimize out),因为编译器通过分析,发觉a,b,c三个变量是无用的,可以进行常量替换。最后的汇编代码相当简介,高效率。
// volatile变量
a,b,c三个变量,都是volatile变量,对应汇编语言,就是三个变量仍旧存在,需要将三个变量从内存读入到寄存器之中,然后再调用printf()函数。
结论:C/C++ 关键字volatile的不可优化性,告诉编译器,不要对我这个变量进行各种激进的优化,甚至将变量直接消除,保证程序员写在代码中的指令,一定会被执行。
3. 顺序性
C/C++ volatile关键词前面提到的两个特性,让volatile经常被解读为一个为多线程而生的关键词:一个全局变量,会被多线程同时访问/修改,那么线程内部,就不能假设此变量的不变性,并且基于此假设,来做一些程序设计:
由于flag变量被声明了volatile属性,因此编译器在编译时,不会每次都从寄存器中读取此变量,同时也不会通过各种激进的优化,只要flag变量在Thread1中被修改,Thread2中就会读取到这个变化,进入if条件判断,然后进入if内部进行处理,问题在于something与flag之间执行顺序,是否与代码是一致的!
// 非volatile变量
全局变量A,B均为非volatile变量。通过gcc O2优化进行编译,你可以惊奇的发现,A,B两个变量的赋值顺序被调换了!!!在对应的汇编代码中,B = 0语句先被执行,然后才是A = B + 1语句被执行。先简单的介绍一下C/C++编译器最基本优化原理:保证一段程序的输出,在优化前后无变化。将此原理应用到上面,可以发现,虽然gcc优化了A,B变量的赋值顺序,但是foo()函数的执行结果,优化前后没有发生任何变化,仍旧是A = 1;B = 0。因此这么做是可行的。
// volatile变量
此用例代码,变量B被声明为volatile变量。通过查看对应的汇编代码,B仍旧被提前到A之前赋值,Volatile变量B,并未阻止编译器优化的发生,编译后仍旧发生了乱序现象,因此前面多线程代码是存在问题的。
// 两个Volatile变量
同时将A,B两个变量都声明为volatile变量,再来看看对应的汇编。奇迹发生了,A,B赋值乱序的现象消失。此时的汇编代码,与用户代码顺序高度一直,先赋值变量A,然后赋值变量B。如此看来,C/C++ Volatile变量间的操作,是不会被编译器交换顺序的。
既然C/C++ Volatile变量间,编译器是能够保证不交换顺序的,那么能不能将something中所有的变量全部设置为volatile呢?
很不幸,仍旧不行。将所有的变量都设置为volatile,首先能够阻止编译器的乱序优化,这一点是可以肯定的。但是,编译器编译出来的代码,最终是要通过CPU来执行的。目前,市场上有各种不同体系架构的CPU产品,CPU本身为了提高代码运行的效率,也会对代码的执行顺序进行调整,这就是所谓的CPU Memory Model (CPU内存模型)。可以参考这些资料:Memory Ordering From Wiki;Memory Barriers Are Like Source Control Operations From Jeff Preshing;CPU Cache and Memory Ordering From 何登成。
下面,是截取自Wiki上的一幅图,列举了不同CPU架构,可能存在的指令乱序:
从图中可以看到,X86体系(X86,AMD64),也就是我们目前使用最广的CPU,也会存在指令乱序执行的行为:StoreLoad乱序,读操作可以提前到写操作之前进行。
针对这个多线程的应用,真正正确的做法是构建一个happens-before语义。关于happens-before语义的定义,可参考文章:The Happens-Before Relation。下面,用图的形式,来展示happens-before语义:
结论:C/C++ 关键字volatile的顺序性,能够保证volatile变量间的顺序性,编译器不会进行乱序优化。volatile变量与非volatile变量的顺序,编译器不保证顺序,可能会进行乱序优化。且需要注意因为CPU的指令优化,即使多线程中都使用volatile,也不能构建happens-before语义
3、总结
根据程序场景,为变量增加volatile关键字,使用volatile的“易变性、不可优化性和顺序性”,但要注意不要扩大volatile的功能,同时须知volatile是针对编译优化的约束,对CPU优化没有作用。 PS:Java对volatile进行了增强,可以保证volatile变量和普通变量的顺序性,且可以避免CPU优化,因此可在多线程环境中使用。 |