volatile字面意思是“不稳定的、易失的”,很多编程语言中存在volatile关键字,也有共同之处,如“表示程序执行期间数据可能会被外部操做修改”,如被外设修改或者被其余线程修改等。这只是字面上给咱们的通常性认识,然而具体到不一样的编程语言中volatile的语义可能相差甚远。css
不少人觉得本身精通CC++,可是被问起volatile的时候却没法清晰、果断地代表态度,那只能说明仍是处在“从入门到精通”的路上,若是了解一门语言常见特性的使用、可以写健壮高效的程序就算精通的话,那实在是太藐视“大师”的存在了。从一个volatile关键字折射出了对CC++标准、编译器、操做系统、处理器、MMU各个方面的掌握程度。html
几十年的发展,不少开发者由于本身的偏见、误解,或者对某些语言特性(如Java中的volatile语义)的根深蒂固的认识,赋予了CC++ volatile本不属于它的能力,本身却浑然不知本身犯了多大的一个错误。java
我曾经觉得CC++中volatile能够保证保证线程可见性,由于Java中是这样的,直到后来阅读Linux内核看到Linus Torvards的一篇文档,他强调了volatile可能带来的坏处“任何使用volatile的地方,均可能潜藏了一个bug”,我为他的“危言耸听”感到吃惊,因此我当时搜索了很多资料来求证CC++ volatile的能力,过后我认为CC++ volatile不能保证线程可见性。可是后来部门内一次分享,分享中提到了volatile来保证线程可见性,我当时心存疑虑,过后验证时犯了一个错误致使我错误地认为volatile能够保证线程可见性。直到我最近翻阅之前的笔记,翻到了几年前对volatile的疑虑……我决定深刻研究下这个问题,以便能顺利入眠。c++
以常见的编程语言C、C++、Java为例,它们都有一个关键字volatile,可是对volatile的定义却并不是彻底相同。算法
Java中对volatile的定义:编程
8.3.1.4.volatile
FieldsThe Java programming language allows threads to access shared variables (§17.1). As a rule, to ensure that shared variables are consistently and reliably updated, a thread should ensure that it has exclusive use of such variables by obtaining a lock that, conventionally, enforces mutual exclusion for those shared variables.缓存
The Java programming language provides a second mechanism,
volatile
fields, that is more convenient than locking for some purposes.网络A field may be declared
volatile
, in which case the Java Memory Model ensures that all threads see a consistent value for the variable (§17.4).多线程
Java清晰地表达了这样一个观点,Java内存模型中会保证volatile变量的线程可见性,接触过Java并发编程的开发者应该都清楚,这是一个不争的事实。架构
CC++中对volatile的定义:
6.7.3 Type qualifiersvolatile: No cacheing through this lvalue: each operation in the abstract semantics must be performed (that is, no cacheing assumptions may be made, since the location is not guaranteed to contain any previous value). In the absence of this qualifier, the contents of the designated location may be assumed to be unchanged except for possible aliasing.
C99中也清晰地表名了volatile的语义,不要作cache之类的优化。这里的cache指的是software cacheing,即编译器生成指令将内存数据缓存到cpu寄存器,后续访问内存变量使用寄存器中的值;须要与之做出区分的是hardware cacheing,即cpu访问内存时将内存数据缓存到cpu cache,硬件操做彻底对上层应用程序透明。你们请将这两个点铭记在心,要想搞清楚CC++ volatile必需要先理解这里cache的区别。
C99清晰吗?上述解释看上去很清晰,可是要想完全理解volatile的语义,绝非上述一句话就能够讲得清的,C99中定义了abstract machine以及sequence points,与volatile相关的描述有多处,篇幅缘由这里就不一一列举了,其中与volatile相关的abstract machine行为描述共同肯定了volatile的语义。
为了引发你们对CC++ volatile的重视并及时代表观点,先贴一个页面“Is-Volatile-Useful-with-Threads”,网站中简明扼要的告知你们,“Friends don’t let friends use volatile for inter-thread communication in C and C++”。But why?
isocpp专门挂了这么个页面来强调volatile在不一样编程语言中的差别,可见它是一个多么难缠的问题。即使是有这么个页面,要完全搞清楚volatile,也不是说读完上面列出的几个技术博客就能解决,那也过轻描淡写了,因此我搜索、整理、讨论,但愿能将学到的内容总结下来供其余开发者参考,我也不想再由于这个问题而困扰。
结合CC++ volatile qualifier以及abstract machine中对volatile相关sequence points的描述,能够肯定volatile的语义:
CC++规范没有显示要求volatile支持线程可见性,gcc也没有在标准容许的空间内作什么“发挥”去安插什么保证线程可见性的处理器指令(Java中volatile会使用lock指令使其余处理器cache失效强制读内存保证线程可见性)。而关于CPU cache一致性协议,x86原先采用MESI协议,后改用效率更高的MESIF,都是强一致性协议,在x86这等支持强一致的CPU上,CC++中结合volatile是能够“得到”线程可见性的,在非强一致CPU上则否则。
可是CC++ volatile确实是有价值的,不少地方都要使用它,并且很多场景下彷佛没有比它更简单的替代方法,下面首先列举CC++ volatile的通用适用场景,方便你们认识volatile,而后咱们再研究为何CC++ volatile不能保证线程可见性。CC++标准中确实没有说volatile要支持线程可见性,你们能够选择就此打住,可是我怀疑的是gcc在标准容许的空间内是怎么作的?操做系统、MMU、处理器是怎么作的?“标准中没有显示列出”,这样的理由还不足以让我停下探索的脚步。
CC++ volatile语义“不可优化型”、“顺序性”、“易变性”,如何直观感觉它的价值呢?看C99中给出的适用场景吧。
设备驱动、Memory-Mapped IO、DMA。
咱们先看一个示例,假如不使用volatile,编译器会作什么。编译器生成代码可能会将内存变量sum、i放在寄存器中,循环执行过程当中,编译器可能认为这个循环能够直接优化掉,sum直接获得了最终的a[0]+a[1]+…a[N]的值,循环体执行次数大大减小。
sum = 0; for (i=0; i<N; ++i) sum += a[i];
这种优化对于面向硬件的程序开发(如设备驱动开发、内存映射IO)来讲有点过头了,并且会致使错误的行为。下面的代码使用了volatile qualifer,其余与上述代码基本相同。若是不存在volatile修饰,编译器会认为最终*ttyport的值就是a[N-1],前N-1次赋值都是不必的,因此直接优化成*ttyport = a[N-1]。可是ttyport是外设的设备端口经过内存映射IO获得的虚拟内存地址,编译器发现存在volatile修饰,便不会对循环体中*ttyport = a[i]进行优化,循环体会执行N次赋值,且保证每次赋值操做都与前一次、后一次赋值存在严格的顺序性保证。
volatile short *ttyport; for (i=0; i<N; ++i) *ttyport = a[i];
可能你们会有疑问,volatile只是避免编译器将内存变量存储到寄存器,对cpu cache却一筹莫展,谁能保证每次对*ttyport的写操做都肯定写回内存了呢?这里就涉及到cpu cache policy问题了。
对于外设IO而言,有两种经常使用方式:
而若是是DMA(Direct Memory Access)操做模式的话,它绕过cpu直接对内存进行操做,期间不中断cpu执行,DMA操做内存方式上与cpu相似,都会考虑cpu cache一致性问题。假如DMA对内存进行读写操做,总线上也会对事件进行广播,cpu cache也会观测到并采起相应的动做。如DMA对内存进行写操做,cpu cache也会将相同内存地址的cache line设置为invalidate,后续读取时就能够从新从内存加载最新数据;假如DMA进行内存读操做,数据可能从其余cpu cache中直接获取而非从内存中。这种状况下DMA操做的内存区域,对应的内存变量也应该使用volatile修饰,避免编译器优化从寄存器中读到旧值。
以上示例摘自C99规范,经过上述示例、解释,能够体会到volatile的语义特色:“不可优化型、易变性、顺序性”。
下面这个示例摘自网络,也比较容易表现volatile的语义特色:
// 应为 volatile unsigned int *p = .... unsigned int *p = GetMagicAddress(); unsigned int a, b; a = *p; b = *p; *p = a; *p = b;
GetMagicAddress()返回一个外设的内存映射IO地址,因为unsigned int *p
指针没有volatile修饰,编译器认为*p中的内容不是“易变的”所以可能会做出以下优化。首先从p读取一个字节到寄存器,而后将其赋值给a,而后认为*p内容不变,就直接将寄存器中内容再赋值给b。写*p的时候认为a == b,写两次不必就只写了一次。
而若是经过volatile对*p进行修饰,则就是另外一个结果了,编译器会认为*p中内容是易变的,每次读取操做都不会沿用上次加载到寄存器中的旧值,而内存映射IO内存区域对应的cpu cache模式又是被uncacheable的,因此会保证从内存读取到最新写入的数据,成功连续读取两个字节a、b,也保证按顺序写入两个字节a、b。
相信读到这里你们对CC++ volatile的适用场景有所了解了,它确实是有用的。那接下来咱们针对开发者误解很严重的一个问题“volatile可否支持线程可见性”再探索一番,不能!不能!不能!
多线程编程中常常会经过修改共享变量的方式来通知另外一个线程发生了某种状态的变化,但愿线程能及时感知到这种变化,所以咱们关心“线程可见性问题”。
在对称多处理器架构中(SMP),多处理器、核心经过总线共享相同的内存,可是各个处理器核心有本身的cache,线程执行过程当中,通常会将内存数据加载到cache中,也可能会加载到寄存器中,以便实现访问效率的提高,但这也带来了问题,好比咱们提到的线程可见性问题。某个线程对共享变量作了修改,线程可能只是修改了寄存器中的值或者cpu cache中的值,修改并不会当即同步回内存。即使同步回内存,运行在其余处理器核心上的线程,访问该共享数据时也不会当即去内存中读取最新的数据,没法感知到共享数据的变化。
有些编程语言中定义了关键字volatile,如Java、C、C++等,对比下Java volatile和CC++ volatile,差别简直是太大了,咱们只讨论线程可见性相关的部分。
Java中语言规范明确指出volatile保证内存可见性,JMM存在“本地内存”的概念,线程对“主存”变量的访问都是先加载到本地内存,后续写操做再同步回主存。volatile能够保证一个线程的写操做对其余线程当即可见,首先是保证volatile变量写操做必需要更新到主存,而后还要保证其余线程volatile变量读取必须从主存中读取。处理器中提供了MFENCE指令来建立一个屏障,能够保证MFENCE以前的操做对后续操做可见,用MFENCE能够实现volatile,可是考虑到AMD处理器中耗时问题以及Intel处理器中流水线问题,JVM从MFENCE修改为了LOCK: ADD 0。
可是在C、C++规范里面没有要求volatile具有线程可见性语义,只要求其保证“不可优化性、顺序性、易变性”。
这里作个简单的测试:
#include <stdio.h> int main() { // volatile int a = 0; int a = 0; while(1) { a++; printf("%d\n", a); } return 0; }
不开优化的话,有没有volatile gcc生成的汇编指令基本是一致的,volatile变量读写都是针对内存进行,而非寄存器。开gcc -O2
优化时,不加volatile状况下读写操做经过寄存器,加了volatile则经过内存。
1)不加volatile :gcc -g -O2 -o main main.c
这里重点看下对变量a的操做,xor %ebx,%ebx将寄存器%ebx设为0,也就是将变量a=0存储到了%ebx,nopl不作任何操做,而后循环体里面每次读取a的值都是直接在%ebx+1,加完以后也没有写回内存。假若有个共享变量是多个线程共享的,而且没有加volatile,多个线程访问这个变量的时候就是用的物理线程跑的处理器核心寄存器中的数据,是没法保证内存可见性的。
2)加volatile:gcc -g -O2 -o main main.c
这里变量a的值首先被设置到了0xc(%rsp)中,nopl空操做,而后a++时是将内存中的值移动到了寄存器%eax中,而后执行%eax+1再写回内存0xc(%rsp)中,while循环中每次循环执行都是先从内存里面取值,更新后再写回内存。可是这样就能够保证线程可见性了吗?No!
是否有这样的疑问?CC++中对volatile变量读写,发出的内存读写指令不会被CPU转换成读写CPU cache吗?这个属于硬件层面内容,对上层透明,编译器生成的汇编指令也没法反映实际执行状况!所以,只看上述反汇编示例是不能肯定CC++ volatile支持线程可见性的,固然也不能排除这种可能性?
Stack Overflow上Dietmar Kühl提到,‘volatile’阻止了对变量的优化,例如对于频繁访问的变量,会阻止编译器对其进行编译时优化,避免将其放入寄存器中(注意是寄存器而不是cpu的cache)。编译器优化内存访问时,会生成将内存数据缓存到寄存器、后续访问内存操做转换为访问寄存器,这称为“software cacheing”;而CPU实际执行时硬件层面将内存数据缓存到CPU cache中,这称为“hardware cacheing”,是对上层彻底透明的。如今已经肯定CC++ volatile不会再做出“将内存数据缓存到CPU寄存器”这样的优化,那上述CPU hardware caching技术就成了咱们下一个怀疑的对象。
保证CPU cache一致性的方法,主要包括write-through(写直达)或者write-back(写回),write-back并非当cache中数据更新时当即写回,而是在稍后的某个时机再写回。写直达会严重下降cpu吞吐量,因此现现在的主流处理器中一般采用写回法,而写回法又包括了write-invalidate和write-update两种方式,可先跳过。
write-back:
- write-invalidate,当某个core(如core 1)的cache被修改成最新数据后,总线观测到更新,将写事件同步到其余core(如core n),将其余core对应相同内存地址的cache entry标记为invalidate,后续core n继续读取相同内存地址数据时,发现已经invalidate,会再次请求内存中最新数据。
- write-update,当某个core(如core 1)的cache被修改成最新数据后,将写事件同步到其余core,此时其余core(如core n)当即读取最新数据(如更新为core 1中数据)。
write-back(写回法)中很是有名的cache一致性算法MESI,它是典型的强一致CPU,intel就凭借MESI优雅地实现了强一致CPU,如今intel优化了下MESI,获得了MESIF,它有效减小了广播中req/rsp数量,减小了带宽占用,提升了处理器处理的吞吐量。关于MESI,这里有个可视化的MESI交互演示程序能够帮助理解其工做原理,查看MESI可视化交互程序。
咱们就先结合简单的MESI这个强一致性协议来试着理解下x86下为何就能够保证强一致,结合多线程场景分析:
这么看来只要处理器的cache一致性算法支持,而且结合volatile避免寄存器相关优化,就能轻松保证线程可见行。可是不一样的处理器设计不同,咱们只是以MESI协议来粗略了解了x86的处理方式,对于其余非强一致性CPU,即使使用了volatile也不必定能保证线程可见性,但如果对volatile变量读写时安插了相似MFENCE、LOCK指令也是能够的?如何进一步判断呢?
还须要判断编译器(如gcc)是否有对volatile来作特殊处理,如安插MFENCE、LOCK指令之类的。上面编写的反汇编测试示例中,gcc生成的汇编没有看到lock相关的指令,可是由于我是在x86上测试的,而x86恰好是强一致CPU,我也不肯定是否是由于这个缘由,gcc直接图省事略掉了lock指令?因此如今要验证下,在其余非x86平台上,gcc -O2优化时作了何种处理。若是安插了相似指令,问题就解决了,咱们也能够得出结论,c、c++中volatile在gcc处理下能够保证线程可见性,反之则不能获得这样的结论!
我在网站godbolt.org交叉编译测试了一下上面gcc处理的代码,换了几个不一样的硬件平台也没发现有生成特定的相似MFENCE或者LOCK相关的导致处理器cache失效后从新从内存加载的指令。
备注:在某些处理器架构下,gcc确实有提供一些特殊的编译选项容许绕过CPU cache直接对内存进行读写,可参考gcc man手册“-mcache-volatile”、“-mcache-bypass”选项的描述。
想了解下CC++中volatile的真实设计“意图”,而后,在stack overflow上我又找到了这样一个回答:https://stackoverflow.com/a/12878500,重点内容已加粗显示。
[Nicol Bolas](https://stackoverflow.com/use...:
Whatvolatile
tells the compiler is that it can't optimize memory reads from that variable. However, CPU cores have different caches, and most memory writes do not immediately go out to main memory. They get stored in that core's local cache, and may be written... eventually.**CPUs have ways to force cache lines out into memory and to synchronize memory access among different cores. These memory barriers allow two threads to communicate effectively. Merely reading from memory in one core that was written in another core isn't enough; the core that wrote the memory needs to issue a barrier, and the core that's reading it needs to have had that barrier complete before reading it to actually get the data.
volatile
guarantees none of this. Volatile works with "hardware, mapped memory and stuff" because the hardware that writes that memory makes sure that the cache issue is taken care of. If CPU cores issued a memory barrier after every write, you can basically kiss any hope of performance goodbye. So C++11 has specific language saying when constructs are required to issue a barrier.
Dietmar Kühl回答中提到:
The volatile keyword has nothing to do with concurrency in C++ at all! It is used to have the compiler prevented from making use of the previous value, i.e., the compiler will generate code accessing a volatile value every time is accessed in the code. The main purpose are things like memory mapped I/O. However, use of volatile has no affect on what the CPU does when reading normal memory: If the CPU has no reason to believe that the value changed in memory, e.g., because there is no synchronization directive, it can just use the value from its cache. To communicate between threads you need some synchronization, e.g., an std::atomic<T>, lock a std::mutex, etc.
最后看了标准委员会对volatile的讨论:http://www.open-std.org/jtc1/...
简而言之,就是CC++中固然也想提供java中volatile同样的线程可见性、阻止指令重排序,可是考虑到现有代码已经那么多了,忽然改变volatile的语义,可能会致使现有代码的诸多问题,因此必需要再权衡一下,到底值不值得为volatile增长上述语义,当前C++标准委员会建议不改变volatile语义,而是经过新的std::atmoic等来支持上述语义。
结合本身的实际操做、他人的回答以及CC++相关标准的描述,我认为CC++ volatile确实不能保证线程可见性。可是因为历史的缘由、其余语言的影响、开发者本身的误解,这些共同致使开发者赋予了CC++ volatile不少本不属于它的能力,甚至大错特错,就连Linus Torvards也在内核文档中描述volatile时说,建议尽可能用memory barrier替换掉volatile,他认为几乎全部可能出现volatile的地方均可能会潜藏着一个bug,并提醒开发者必定当心谨慎。
凡事都没有绝对的,用不用volatile、怎么用volatile须要开发者本身权衡,本文的目的主要是想总结CC++ volatile的“能”与“不能”以及背后的缘由。因为我的认识的局限性,不免会出现错误,也请你们指正。