volatile
并不好,也很容易被误解。我们原本不应该在本节中讨论它,因为它跟并发编程没有任何关系。但是在其它编程语言,如Java和C#中,它却能够用于并发编程。一些C++编译器,也扩展了volatile
的语意,使其能够用于并发程序,当然,必须用这些编译器编译才行。因此,即使是为了消除对volatile
模棱两可的认知,在本节讨论volatile
也是值得的。
程序员有时候容易混淆volatile
和std::atomic
的用途,而本节确切要讨论的正是std::atomic
模板,模板化实例如std::atomic<int>
、std::atomic<bool>
、std::atomic<Widget*>
等,提供了一系列原子操作,这些操作能够保证多线程间的原子性。std::atomic
实例一旦构造出来,在实例上进行的所有操作就跟它处于互斥临界区的行为一样。事实上,这些操作通常是利用特殊的机器指令实现的,因此其执行效率比使用临界区要高。
考虑以下使用std::atomic
实现的代码:
std::atomic<int> ai(0); // ai初始化为0
ai = 10; // 原子的将ai设为10
std::cout << ai; // 原子性读取ai的值
++ai; // 原子性递增为11
--ai; // 原子性递减为10
在这段代码执行期间,如果只有当前执行线程修改ai
,那么其它线程读取到ai
的值只可能是0、10或者11。
这段代码有两点值得注意:首先,对于语句”std::cout << ai;
“,ai
是std::atomic
类型的只能保证读取ai
的操作是原子的,并不能保证”std::cout << ai;
“的执行也是原子的,在读取ai
之后、调用操作符<<
将ai
输出到标准输出之前,其它线程还是有可能改变ai
的值。但这并不影响这条语句的行为,因为int
的输出操作符<<
采用的是传值传递的方式(operator<<
成员函数的参数采用传值传递的方式,读取完ai
后立即拷贝传递给形参,因此,输出值跟读取到的ai
值一致)。请牢记:这条语句中,只有读取ai
值的操作是原子的。
其次,上例中最后两条语句——自增和自减的的行为也值得细细推敲。尽管看起来是原子操作,但他们其实都是读-改-写(RMW)操作的组合,这是std::atomic
类型的最佳特性之一。std::atomic
对象一旦被构造出来,该对象的所有的成员函数,包括RMW操作,都能保证多线程间的原子性。
作为对比,在多线程环境下,这段用volatile
实现的代码实质上什么都保证不了:
volatile int vi(0); // vi初始化为0
vi = 10; // vi置为10
std::cout << vi; // 读取vi的值
++vi; // vi自增为11
--vi; // vi自减为10
在这段代码执行期间,如果其它线程想读取vi
的值,则它可能读到任何值,比如-12、68、4090727,任何值都有可能!这样的代码行为是未定义的:因为代码本身会修改vi
的值,如果同时还有其它线程读取vi
的值,则读取线程和写入线程同时访问相同的内存,而这片内存既非std::atomic
的,也没有互斥锁保护,就产生了数据竞争。
举一个更具体的例子,来说明std::atomic
和volatile
在多线程编程时的行为差异。考虑一个多线程环境下的简单计数器,初始值都为0:
std::atomic<int> ac(0); // 原子计数器
volatile int vc(0); // volatile计数器
分别在两个线程中同时递增这两个计数器:
/*----- Thread 1 -----*/ /*----- Thread 2 -----*/
++ac; ++ac;
++vc; ++vc;
当两个线程执行完时,ac
的值一定是2,因为每次递增都是不可拆分的原子操作。然而,vc
的值不一定是2,因为其自增操作不一定表现为原子的:每次自增首先读取vc
的值,然后递增读取到的值,再将结果写回到vc
。对于volatile
对象,这三个操作并不保证是原子性的,vc
的两次递增操作有可能是按一下顺序交错执行的:
- 1. 线程1读取到
vc
的值0 - 2. 线程2读取到
vc
的值,也是0 - 3. 线程1将读取到的0递增为1,然后写回到
vc
- 4. 线程2将读取到的0递增为1,然后写回到
vc
尽管递增了两次,vc
的值最终还是1.
这并不是唯一可能的输出结果,一般而言,vc
的最终结果是不可预测的,因为vc
存在数据竞争。数据竞争会导致未定义行为,这意味着编译器可能生成代码做任何符合语法的事情。当然,编译器不会蓄意利用这条规则作恶,相反的,编译器会对代码进行优化以避免数据竞争,在出现数据竞争时,这些优化往往导致程序出现不可预知的行为。
RMW并不是std::atomic
适用而volatile
不适用的唯一场景。考虑以下情形:任务1需要计算一个非常重要的值,而这个值将作为任务2的输入。当任务1计算完这个值,它必须将值传递给任务2。条款39指出,告诉任务2该值可用的方法之一是:在任务1中使用一个std::atmic<bool>
的变量标识该变量是否可用。计算的代码看起来可能像这样:
std::atmic<bool> valAvailable(false);
auto imptValue = computeImportantValue(); // 计算得到的值
valAvailable = true; // 告诉下一任务,该值已经可用
从人类阅读的角度看,imptValue
的赋值很显然发生在valAvailable
赋值之前,但在编译器看来,这只是一对相互独立的变量赋值语句。作为通行做法,编译器会对这些不相关的赋值进行语句重排。也就是说,给定一组赋值语句,其中a
、b
、x
和y
是相互独立的变量,
a = b;
x = y;
编译器有可能按如下顺序重排:
x = y;
a = b;
即使编译器不进行指令重排,底层的硬件也可能会这么做,因为重排通常能使代码运行的更快。
而std::atomic
强制规定了代码能被怎样重排,其中一条规定是,源代码中在std::atomic
变量写操作之前的代码,不能在写操作之后执行。这意味着,
auto imptValue = computeImportantValue(); // 计算值
valAvailable = true; // 告知该值可用
编译器不仅需要保持imptValue
和valAvailabe
的赋值顺序,还必须保证生成的代码在底层硬件的执行顺序也不变。因此,声明valiAvailable
为std::atomic
类型,保证了严格的执行次序——即对于所有的线程,必须在读到valAvailable
的新值之前能读到imptValue
的新值。
将valAvailable
声明为volatile
并不会强加这样的语句重排限制:
volatile bool valAvailable(false);
auto imptValue = computeImportantValue();
valAvailable = true; // 其它线程有可能在imptValue赋值
// 之前看到该值
这里,编译器对imptValue
和valAvailable
的赋值顺序可能出现跳跃(即顺序是不一定的),即使能保证顺序,编译器也不能保证生成的机器指令在不同处理器上执行时都保证赋值顺序。
我们从两个角度:1)不能保证操作的原子性,以及2) 不限制代码重排,解释了volatile
不能用于并发编程的原因,但是并没有说明volatile
的真正用途。简而言之,volatile
告诉编译器,他们正在处理非普通行为的内存。
**
“普通”内存是指,如果你向该内存中写入一个值,这个值将一直存在,直到被其它某个值覆写。例如,普通的int
值,
int k;
编译器顺序执行以下操作:
auto y = x; // 读取x
y = x; // 再次读取x
编译器通过消除y
的赋值来优化生成的代码,因为y
的初始化操作是多余的。
普通内存还意味着,如果向该内存中写入一个值,并且永远不去读取,则再次向该内存写入值时,第一次的写入操作被移除,因为它从未被用到。给定两个相邻的语句:
x = 10;
x = 20;
编译器将移除第一句。这意味着如果我们的源代码中有如下语句:
auto y = x;
y = x;
x = 10;
x = 20;
编译器将按照以下语句处理:
auto y = x;
x = 20;
你也许会问谁会写这种冗余读和冗余写(称为冗余负载和无用存储)的代码,答案是我们通常不会直接写出这样的代码。但是,经过编译器调整代码、执行模板实例化、内联,以及各种常见的代码重排优化后,有冗余负载和无用存储代码并不罕见。
这些优化只有在正常内存下才有效,专有内存(或称为特殊内存)则无效。一种最常见的专有内存,可能就是用于I/O映射的内存。与用于读写的普通内存(如RAM)不同,这些内存实际用于与外围设备通信,例如外部传感器或者显示器、打印机、网口等。再次考虑冗余读:
auto y = x;
y = x;
如果x
是温度传感器的值,则第2次读取x
就不是冗余的,因为第1次和第2次读取的温度值可能已经发生了变化。
考虑类似情形下的冗余写:
x = 10;
x = 20;
如果x
是无线电发射器的控制端口,以上代码是向无线电发射器传输的指令,10和20代表的是不同的指令。对第1个赋值进行彻底优化,将改变发送到无线电发射器的指令。volatile
**用于告诉编译器,我们正在处理专有内存,”在这片内存上,不要对操作进行任何优化”。因此,如果x
是专有内存,就用volatile
声明它:**
volatile int x;• 1
考虑一下我们的原始代码的顺序会有怎样的影响:
auto y = x; // 读取x
y = x; // 再次读取x(不会进行优化)
x = 10; // 写入x(不进行优化)
x = 20; // 再次写入x• 1
如果x
是内存映射对象(或者是映射到多线程共享对象),这正是我们所期望的结果。
提个小问题,在上面的代码片段中,y
是什么类型的:int
还是volatile int
?std::atomic
并不适合处理专有内存,因为编译器会消除冗余操作。显而易见的是,专有内存要求必须保留这些冗余负载和无用存储。因此,不能像写volatile
代码那样写std::atomic
代码,如果我们细细思考一下编译器究竟会做哪些事情的话,很容易想到编译器可能会将下面的代码:
auto y = x; // 理论上应该会读取x(见下文)
y = x; // 理论上会再次读取x(见下文)
x = 10; // 写入x
x = 20; // 再次写入x
优化成如下结构:
auto y = x; // 理论上会读取x(见下文)
y = x; // 写x
对于专有内存,这显然是不能接受的。
不幸的是,若x
是std::atomic
类型时,以上两段代码都不能通过编译:
auto y = x; // 错误
y = x; // 错误
因为std::atomic
的拷贝操作是定义成deleted
(删除)类型的(见条款11)。考虑一下,如果x
能够通过编译,那么利用x
初始化y
会出现什么结果呐?因为x
是std::atomic
类型的,y
的推断类型也将是std::atomic
。我之前提到过,std::atomic
的最大优势之一是:它的所有操作都是原子的,为了实现原子的从x
拷贝构造y
,编译器生成的代码,必须在单个原子操作中读取x
的值,并写入y
。遗憾的是,硬件通常不支持这么做,因此std::atomic
类型不支持拷贝构造函数。基于同样的原因,std::atomic
也被定义为deleted
的,这也解释了为什么将x
赋值给y
无法通过编译。(std::atomic
类型并未显式定义移动操作符,因此根据条款17所述的特殊函数生成规则,编译器不会为std::atomic
生成移动构造函数,以及移动赋值操作)。
利用std::atomic
的成员函数load
和store
,可以将x
的值复制给y
。成员函数load
用于原子的读取一个std::atomic
对象的值,而成员函数store
原子的将读取的值写入对象。为了用x
初始化y
,即将x
的值拷贝给y
,代码可以这样写:
std::atomic<int> y(x.load()); // 读取x
y.store(x.load()); // 再次读取x
这段代码可以正常编译。实际上,读取x
(通过x.load()
)是初始化y
或者store
至y
过程中的一次单独的函数调用,因此不能期待以上的初始化或store
操作语句是原子执行地。
对于以上代码,编译器可能这样进行优化:将x
的值保存在寄存器中,而不是读取两次:
register = x.load(); // 将x读入寄存器
std::atomic<int> y(register); // 用寄存器值初始化y
y.store(register); // 将寄存器值保存至y
正如你所看到的,这样只需要读取一次x
,但是这种优化在专有内存中必须避免。(volatile
不允许进行这样的优化)
结论很显然:
std::atomic
在并发编程中非常有用,但不适合访问专有内存;volatile
适用于访问专有内存,但不能用于并发编程;
std::atomic
和volatile
有不同的适用场景,它们也可以一起使用:
volatile std::atomic<int> vai; // vai的所有操作都是原子
// 的,并且不能进行优化
如果vai
是用于多线程并发访问的内存I/O映射,这样的定义非常有用。
最后需要强调的是,有些开发人员倾向于使用std::atomic
的load
和store
成员函数,即使并不一定非用这些函数不可,这样很直白的在源代码中指出这些变量不是”普通”变量,这一点很重要。正如我们看到的,与其它类型不同,std::atomic
禁止编译器进行某些代码指令重排,因此访问std::atomic
对象通常远远慢于非std::atomic
对象。调用std::atomic
的load
和store
成员函数能帮我们标识潜在的检查点。记住,如果一个用于多线程间共享的变量(如用于标识数据是否可用性的变量)没有调用store
函数,就意味着该变量不是std::atomic
类型的,尽管它本应该定义为std::atomic
类型。
本节主要讨论的是一个设计上的问题(std::atomic
和volatile
设计用于不同的目的),选择std::atomic
还是volatile
,结果是完全不一样的。