可怜的volatile。被误解到如此地步。它甚至不应该出现在本章中,因为它与并发程序设计毫无关系。但是在其他程序设计语言中(例如 Java 和 C# ),它还是会对并发程序设计有些用处。甚至在C++中,一些编译器也已经把volatile投入了染缸,使得它的语义显得可以用于并发软件中(但是仅可能用于使用这些编译器进行编译之时)。
因此,除了消除环绕在它周围的混淆视听外,没有什么其他的理由值得在关于并发的一章中讨论volatile。
程序员有时会把volatile与绝对属于本章讨论范围的另一C++特性混淆,那就是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; //原子地将ai自增为11
--ai; //原子地将ai自减为10
这些语句的执行期间,其他读取ai的线程可能只会看到它取值为0、10或11,而不可能有其他的取值(当前,前提假设这是修改ai值的唯一线程)。
std::atomic 注意点一:原子性的仅覆盖到对象的API而不是整个语句
此例在两方面值得注意。首先,在std::cout << ai; 这个语句中,ai是std::atomic这一事实只能保证ai的读取是原子操作。至于整个语句都以原子方式执行,则没有提供如此保证。在读取ai的值和调用operator<<将其写入标准输出之间,另一个线程可能已经修改了ai的值。这对语句的行为没有影响,因为整型的operator<<会使用按值传递的int型别的形参来输出(因此输出的值会是从ai读取的值),重点在于了解这个语句中具备原子性的部分仅在于ai的读取而不涉及其余更多部分。
std::atomic 注意点二:原子对象的所有API均为原子的
此例子第二个值得注意的方面是最后两个语句的行为————ai的自增和自减。这里想发个都是读取——修改——写入(read-modify-write,RMW)操作,但皆以原子方式进行执行。这是std::atomic型别最棒的特性之一:一旦构造出std::atomic型别对象,其上所有的成员函数(包括那些包含RMW操作的成员函数)都保证被其他线程视为原子的。
1. 数据竞险 Data Race
对比之下,使用volatile的相应代码在多线程语境中几乎不能提供任何保证:
volatile int vi(0); //将vi初始化为0
vi = 10; //将vi设置为10
std::cout << vi; //读取vi的值
++vi; //将vi自增为11
--vi; //将vi自减为10
在这段代码的执行期间,如果其他线程正在读取vi的值,它们可能会看到任何值,例如-12、23423、2672389,任何值!这样的代码会出现未定义的行为,因为这些语句修改了vi,所以如果其他线程同时正在读取vi,就会出现在既非std::atomic,也非由互斥量保护的同时读写操作,这就是数据竞险(Data Race)的定义。
为了说明std::atomic型别对象和volatile的行为在多线程程序中会有怎样的差异,这里举个具体例子,考虑两者由多个线程执行自增的简单计数器。两者都初始化为0:
std::atomic<int> ac(0); //"ac"是"atomic counter"(原子计数器)缩写
volatile int vc(0); //"vc"是"volatile counter"(挥发计数器)的缩写
而后,我们在两个同时运行的线程中将两者各自增一次:
/*****线程1****/ /*****线程2****/
++ac; ++ac;
++vc; ++vc;
当两个线程都完成后,ac的值(即,std::atomic型别对象的值)必定是2,因为它的自增都是作为不可分割的操作出现的。另一方面,vc的值则不一定是2,因为它的自增可能会不以原子方式发生。每次自增包括:读取vc的值,自增读取的值,并将结果写回vc。但这三个操作皆不能保证以原子方式处理volatile对象,所以可能两次vc自增的组成部分会交错进行,如下所示:
- 线程1读取vc的值,即0。
- 线程2读了vc的值,仍为0。
- 线程1把读取的值0自增为1,并将该值写入vc。
- 线程2把读取的值0自增为1,并将该值写入vc。
- 这么一来,vc最终值为1,即使它被实施了两次自增操作。
这并不是唯一可能的结果,vc的最终取值一般来说是无法预测的,因为vc涉及数据竞险,而标准既然裁定数据竞险会导致未定义行为,意味着编译器可能会生成代码来做任何事情。当然,编译器一般不会利用这种保留余地来做什么恶。可是,它们会执行一些在对于没有数据竞险的程序而言有效的优化,但这些优化在存在数据竞险的程序则会产生意想不到的、无法预测的行为。
再来一个例子
RMW操作的使用并不是唯一让std::atomic型别对象在并发条件下成功,而让volatile失败的情况。假设一个任务负责计算第二个任务所需的重要值。当地一个任务已经计算出该值时,它必须把这个值通信到第二个任务。Item 39 解释过,要使第一个任务将所需值的可用性传递给第二个任务,有一种方法就是使用std::atomic<bool>。在负责计算的任务中,代码会长成这样:
std::atomic<bool> valAvailable(false);
auto impValue = computeImportValue(); //计算值
valAvailable = true; //通知其他任务值已可用
当人类在阅读这段代码的时候,都会知道在为valAvailable赋值之前为impValue赋值这一点至关重要,但是编译器所能看到的一切,不过是一对针对独立变量实施的赋值操作。一般地,编译器可以将这些不想关的赋值重新排序。换而言之,给定下面的赋值序列(其中,a,b,x,y对应于独立变量),
a = b;
x = y;
编译器可以自行将其重新排序成下面这样:
x = y;
a = b;
即使编译器未对它们进行重新排序,底层硬件也可能会这样做(或者可能会让其他内核将其视为重新排序后的样子),因为这样做有时候会是代码运行的更快。
然而,std::atomic型别对象的运用会对代码可以如何重新排序加以限制,并且这样的限制之一,就是在源码中,不得将任何代码提前至后续会出现std::atomic型别变量的写入操作的位置(或使其他内核视作这样的操作会发生)。
这意味着在我们的代码中,
auto impValue = computeImportValue(); //计算值
valAvailable = true; //通知其他任务值已可用
不仅编译器必须保持为impValue和valAvailable的赋值顺序,它们还必须生成代码以确保底层硬件也保证这个顺序。
因此,将valAvailable声明为std::atomic型别可以确保我们的关键顺序需求得到保证,impValue必须被所有线程看到,它是以不晚于valAvailable的时序被更改。
将valAvailable加上volatile声明饰词,不会给代码施加同样的重新排序方面的约束:
volatile bool valAvailable(false);
auto impValue = computeImportValue(); //计算值
valAvailable = true; //其他线程可能将这个赋值操作视作
//在impValue之前!
在这里,编译器可能会将赋值顺序反转为后impValue
先valAvailable
,即使它不这么做, 也可能不会生成及其代码阻止底层硬件使其他内核上的代码看到valAvailable
在impValue
之前发生改变。
2. 接下来学习一把 volatile
这两个那问题(无法保证操作的原子性,无法对代码重新排序施加限制)解释了为何volatile
对并发编程没用,但是并未解释它在什么情况下有用。简而言之,它的用处就是告诉编译器,正在处理的内存不具备常规行为。
常规内存
“常规”内存的特征是: 如果你向某个内存位置写入了值,该值会一直保留在那里,直到它被覆盖为止。所以,如果我有个常规的int
变量:
int x;
且编译器看到了对其实施了以下序列的操作:
auto y = x; //读取x
y = x; //再次读取x
编译器可以通过消除对y
的赋值操作来优化生成新的代码,因为它和y
的初始化形成了冗余。
常规内存还有如下特征:如果向某内存位置写入某值,期间未读取该内存位置,然后再次写入该内存位置,则第一次写入可以消除,因为其写入结果从未被使用过。所以给定下面的两个相邻语句:
x = 10; //写入x
x = 20; //再次写入x
编译器就可以消除第一个操作,这意味着如果我们在源代码中有这样一段:
auto y = x; //读取x
y = x; //再次读取x
x = 10; //写入x
x = 20; //再次写入x
编译器可以自行把这段代码视作长成下面这样一般:
auto y = x; //读取x
x = 20; //写入x
恐怕你会想,谁会撰写执行如此的冗余读取和多余写入的代码(术语是冗余加载和废弃存储)呢?答案是,人类不会直接撰写如此代码,至少我们希望没人会这样做吧。但是,即使编译器接受的是看上去合情合理的源代码,对其执行模板实例化、内联以及各种常见的重新排序等优化后,结果中包含编译器能够消除的冗余加载和废弃存储的情况并不罕见。
特种内存
此类优化仅在内存行为符合常规时才合法。“特种”内存就是另一回事。
可能最常见的特种内存是用于内存映射IO的内存。这种内存的位置实际上是用于与外部设备(例如,外部传感器、显示器、打印机和网络端口等)通信,而非用于读取或写入常规内存(即RAM)。在此情况下,再次考虑看似冗余的代码:
auto y = x; //读取x
y = x; //再次读取x
如果x
对应于,比如说,由温度传感器报告的值,则x
的第二次读取操作并非多余,因为在第一次和第二次读取之间,温度可能已经改变。
看似多余的写入操作也有类似的情形。比如,在这段代码中:
x = 10; //写入x
x = 20; //再次写入x
如果x对应于无线发射器的控制端口,则可能是代码在向无线电发出指令,并且值10对应于与值20不同的命令。如果把第一个赋值优化掉,就将改变发送到无线电的命令序列了。
而volatile的用处就是告诉编译器,正在处理的是特种内存。它的意思是通知编译器“不要对在此内存上的操作做任何优化”。所以,如果x对应于特种内存,则它应该加上volatile声明饰词:
volatile int x;
考虑这么一来,会对我们原先的代码序列产生什么影响:
auto y = x; //读取x
y = x; //再次读取x(不会被优化掉了!)
x = 10; //写入x
x = 20; //再次写入x(不会被优化掉了!)
如果x
是内存映射的(或已映射到跨进程共享的内存位置等),这真正是我们想要的效果。
测试时间!在上面最后一段代码中,y应该取什么型别:int
还是volatile int
?
在处理特种内存时必须保留看似冗余加载和废弃存储这一事实,也顺便解释了为何std::atomic型别对象不适用于这种工作。编译器可以消除std::atomic型别上的冗余操作。代码的撰写方式与使用volatile时不尽相同,但是我们不妨暂时忽略这一点。而先关注编译器允许做的事情,我们可以这么说,从概念上说,编译器可能接受的是这样的代码:
std::atomic<int> x;
auto y = x; //概念上会读取x(见下)
y = x; //概念上会再次读取x(见下)
x = 10; //写入x
x = 20; //再次写入x
并优化成下面这样:
auto y = x; //概念上会读取x(见下)
x = 20; //写入x
这显然对于特种内存来说,是不可接受的行为。
无巧不成书,以下两个语句在x
是std::atomic
型别对象时都不能通过编译:
auto y = x; //错误!
y = x; //错误!
原因在于std::atomic的复制操作被删除了,参见Item 11。而且这个删除是有充分道理的。考虑如果从x触发来初始化y能够通过编译的话,会发生什么。
由于x的型别是std::atomic,所以y的型别也会被推导为std::atomic,参见Item 2。我之前说过,std::atomic型别对象最好的一点,是它们的所有操作都是原子的。但是,为了使得从x出发来构造y的操作也成为原子的,编译器就必须生成代码来在单一的原子操作中读取x并写入y。硬件通常无法完成这样的操作,这就是为什么从x到y的赋值通不过编译的原因(由于移动操作没有在std::atomic中显示声明,因此,根据Item 17中描述的编译器生成特种函数的规则,std::atomic既不提供移动构造,也不提供移动赋值运算符。)
从x中取值并置入y是可以实现的,但是要求使用std::atomic的成员函数load和store。load成员函数以原子方式读取std::atomic型别对象的值,而store成员函数以原子方式写入之。如果想先用x初始化y,然后将x的值置入y,代码必须如下撰写:
std::atomic<int> y(x.load()); //读取x
y.store(x.load()); //x再次读取
这段代码可以通过编译,但是,读取x(经由x.load())是个独立于初始化或存储到y的函数调用这一事实清楚地表明,没有理由去期望这两条语句中的任何一条可以整体作为单一原子操作执行。
给定上述代码的前提下,编译器可以通过将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> val; //针对val的操作是原子的,
//并且不可以被优化掉
如果val
对应于由多个线程同时访问的内存映射IO位置,就可能会是有用的。
3. 小Tips
最后,有些开发人员更喜欢使用std::atomic的load和store成员函数,即使并非必要,因为这样做可以在源代码中明确地表明所涉及的变量并非“常规”。强调这一事实,也并非没有理由。访问std::atomic型别对象通畅比访问非std::atomic型别对象慢得多,我们已经看到std::atomic型别对象在使用过程中会阻止编译器对某些类型的代码重新排序,而这样的重新排序在其他情况下是被允许的。召唤std::atomic型别对象的加载和存储有助于识别出阻碍潜在的可伸缩性之处。从正确性角度来看,如果本来想要通过某个变量将信息传达到其他线程,却未见它调用store(例如,一个指示数据可用性的标志位),就可能意味着该变量本来应该声明为std::atomic,却没有这么做。
这在很大程度上是一个代码风格的问题,因此,这与在std::atomic和volatile之间进行的选择有着非常不同的性质。