C++ volatile的作用
400 Words | Read in about 2 Min | View times
Overview
C++中volatile
和const
对应,都是用来修饰变量的关键字。volatile
关键字通常用来建立语言级别的内存屏障(memory barrier)。为什么要使用volatile
?volatile
关键字的含义是什么?volatile
与多线程有什么关系?本节内容将一一解答。
本系列文章将包括以下领域:
本章其他内容请见 《现代C++》
为什么要使用volatile?
volatile
从字面上理解,是指被它修饰的对象有可能突然不可预期地发生变化。对于程序来说,代码的任何行为都必须是可预期的。那么在程序中什么样的情况才叫volatile
。程序可能受到程序本身之外的因素影响,比如操作系统、硬件或者其他线程等。
我们来看一个例子:
1int i = 10;
2
3int a = i;
4
5//其他代码并未明确告诉编译器对i进行过操作
6...
7
8int b = i;
上面这段代码中,编译器发现两次从i
读取数据的代码之间没有对i
进行过操作,因此编译器会对指令进行优化:第一次读取i
时访存取数据后,把它放置在CPU寄存器中;第二次读取i
时为了提升效率,不再从内存读取数据,而是直接从CPU寄存器中读取。
但是请注意,我们强调的是代码“没有明确地”提示编译器会改变内存中的i
,编译器就会进行指令优化。什么情况是“没有明确地”提示编译器呢?我们接着往下看:
1int i = 10;
2
3int a = i; //a = 10
4
5//插入一段汇编代码,用来改变内存中i的值为32
6//通过插入汇编代码的方式改变代码行为的方式,不是“明确地”提示编译器。
7__asm {
8 mov dword ptr [ebp-4], 20h
9}
10
11int b = i; //b = 10
结果显示第二次读取i
时,并没有重新访存读取最新的数值,而是直接读取了CPU缓存,导致运行结果与预期不符合。为了解决这种非预期的问题,volatile
应运而生。来看一下加入volatile
修饰后的结果:
1volatile int i = 10;
2
3int a = i; //a = 10
4
5//插入一段汇编代码,用来改变内存中i的值为32
6//通过插入汇编代码的方式改变代码行为的方式,不是“明确地”提示编译器。
7__asm {
8 mov dword ptr [ebp-4], 20h
9}
10
11int b = i; //b = 32
结果显示第二次读取i
时,总是重新访存读取最新数值,不再从CPU缓存读取,这样依赖代码就符合预期了。
volatile关键字的含义
在cppreference网站上,对volatile
关键字有如下解释:
volatile object - an object whose type is volatile-qualified, or a subobject of a volatile object, or a mutable subobject of a const-volatile object. Every access (read or write operation, member function call, etc.) made through a glvalue expression of volatile-qualified type is treated as a visible side-effect for the purposes of optimization (that is, within a single thread of execution, volatile accesses cannot be optimized out or reordered with another visible side effect that is sequenced-before or sequenced-after the volatile access. This makes volatile objects suitable for communication with a signal handler, but not with another thread of execution, see std::memory_order). Any attempt to refer to a volatile object through a glvalue of non-volatile type (e.g. through a reference or pointer to non-volatile type) results in undefined behavior.
翻译过来就是,对被volatile
关键字修饰的泛左值glvalue
的每一种操作(读写、成员函数调用等),会引发一些可观测的编译器优化副作用。
在C++中,对volatile
对象的访问,有编译器优化上的副作用:
-
不允许被优化消失(optimized out):编译器不能做任何假设和推理,必须按部就班地与内存进行交互(访存),复用CPU寄存器中的值是不允许出现的。
-
执行顺序在另一个
volatile
对象的访问之前:两个表达式E1
和E2
之间,E1
的求值动作和副作用都优先于E2
的求值动作和副作用。但是这种对volatile
对象访问的序列性,在多线程环境中不一定成立。
volatile与多线程有什么关系?
上一小节我们提到,对volatile
对象访问的序列性,在多线程环境中不一定成立,这是什么意思呢?
我们先从简单的volatile
解决线程间变量读取方式的问题说起。
我们现在很明确了,volatile
就是阻止编译器优化(把变量放入CPU寄存器),而是用时必须从内存中真正取出。如果两个线程中一个读取寄存器,一个读取内存,那么势必造成逻辑错误。来看这个例子:
1//main thread
2volatile bool flag = false;
3
4//thread 1
5while (!flag) {...}
6flag = false;
7
8//thread 2
9flag = true;
10while (flag) {...}
如果flag
不使用volatile
修饰,那么thread2中的while
将陷入死循环,因为flag
已经读取到CPU寄存器了,while
语句在读取flag
时不会从内存重新读取,而是一直从CPU寄存器读取,等同于while (true)
,因此进入死循环。加上volatile
关键字之后,while
语句每次只能从内存里读取flag
的最新值,就不会进入死循环。
然而,事情有想象的这样简单吗?我们进一步考虑一种情况:
1//main thread
2volatile bool flag = false;
3
4//thread 1
5MyType* value = new MyType();
6thread2(value);
7while (flag) { apply(value); }
8flag = false;
9t2.join();
10
11//thread 2
12value->update();
13flag = true;
在这个例子中,flag
被volatie
修饰,确实解决了while
死循环的问题,但是value
并不是。编译器仍有可能在优化thread2时,把value->update()
和flag = true
交换顺序,因为两句没有直接关系,编译器优化可能会对这里的顺序进行优化。从逻辑上看,如果交换了顺序,那么在thread1中调用apply(value)
时,value
仍然有可能是旧的。
如果再对value
加上volatile
修饰呢?聪明的你一定看出来了,value
加上volatile
之后会阻止编译器优化,因而不会交换它们的顺序了。但是!
我们的代码最终是在CPU中执行的,CPU的memory_order
不同可能会导致指令是乱序执行的,在CPU执行时,value->update()
和flag = true
仍然可能被交换执行顺序。所以这种情况下,volatile
并不能解决序列性的问题,还可能因为禁止编译器优化导致代码效率低下。所以我们有前面的结论:对volatile
对象访问的序列性,在多线程环境中不一定成立。
正确的做法应该是使用std::atomic<bool>
,搭配std::memory_order
可以构建良好的内存屏障。
内存屏障(英语:Memory barrier),也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,它使得 CPU 或编译器在对内存进行操作的时候, 严格按照一定的顺序来执行, 也就是说在内存屏障之前的指令和之后的指令不会由于系统优化等原因而导致乱序。
大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。
语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。
也可以使用互斥量、条件变量等多线程同步技术。我们将在后面的章节展开。