可怜的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 = 10;
std::cout << ai;
++ai;
--ai;
这些语句的执行期间,其他读取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 = 10;
std::cout << vi;
++vi;
--vi;
在这段代码的执行期间,如果其他线程正在读取vi的值,它们可能会看到任何值,例如-12、23423、2672389,任何值!这样的代码会出现未定义的行为,因为这些语句修改了vi ,所以如果其他线程同时正在读取vi,就会出现在既非std::atomic ,也非由互斥量保护的同时读写操作,这就是数据竞险(Data Race )的定义。
为了说明std::atomic 型别对象和volatile 的行为在多线程程序中会有怎样的差异,这里举个具体例子,考虑两者由多个线程执行自增的简单计数器。两者都初始化为0:
std::atomic<int> ac(0);
volatile int vc(0);
而后,我们在两个同时运行的线程中将两者各自增一次:
++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 型别变量的写入操作的位置(或使其他内核视作这样的操作会发生)。
插播译者解释 这一点仅在std::atomic 型别对象采用顺序一致性时才成立,这种一致性是默认采用的,也是本书中使用该语法时唯一采用的一致性模型。C++还支持另外的、在代码重排方面更灵活的一致性模型。这样的弱化(也称作松弛)模型使得在某些硬件体系结构上运行得更快的软件成为可能,但是运用这样的模型所产生的软件想要保证正确性、可理解性和可维护性,会困难得多。在松弛原子性中的微妙代码错误绝不罕见,即使专业也会感觉棘手。所以但凡可能,你就应该抱紧顺序一致性
插播自己的见解 在能够理解六种内存顺序的基础上,其实可以灵活的使用松弛顺序去保证那些可能造成竞态的单一变量。松弛顺序实际上比顺序一致性更适合用于纯粹为了避免竞态保护数据的场景。以上自己见解中有对应PPT,传送门
这意味着在我们的代码中,
auto impValue = computeImportValue();
valAvailable = true;
不仅编译器必须保持为impValue 和valAvailable 的赋值顺序,它们还必须生成代码以确保底层硬件也保证这个顺序。
因此,将valAvailable 声明为std::atomic 型别可以确保我们的关键顺序需求得到保证,impValue 必须被所有线程看到,它是以不晚于valAvailable 的时序被更改。
将valAvailable 加上volatile 声明饰词,不会给代码施加同样的重新排序方面的约束:
volatile bool valAvailable(false);
auto impValue = computeImportValue();
valAvailable = true;
在这里,编译器可能会将赋值顺序反转为后impValue 先valAvailable ,即使它不这么做, 也可能不会生成及其代码阻止底层硬件使其他内核上的代码看到valAvailable 在impValue 之前发生改变。
2. 接下来学习一把 volatile
这两个那问题(无法保证操作的原子性,无法对代码重新排序施加限制)解释了为何volatile 对并发编程没用,但是并未解释它在什么情况下有用。简而言之,它的用处就是告诉编译器,正在处理的内存不具备常规行为。
这里我有个更简单的理解方案,就是告诉编译器别乱优化我的代码,就按照我写的来
常规内存
“常规”内存的特征是: 如果你向某个内存位置写入了值,该值会一直保留在那里,直到它被覆盖为止。所以,如果我有个常规的int 变量:
int x;
且编译器看到了对其实施了以下序列的操作:
auto y = x;
y = x;
编译器可以通过消除对y 的赋值操作来优化生成新的代码,因为它和y 的初始化形成了冗余。
常规内存还有如下特征:如果向某内存位置写入某值,期间未读取该内存位置,然后再次写入该内存位置,则第一次写入可以消除,因为其写入结果从未被使用过。所以给定下面的两个相邻语句:
x = 10;
x = 20;
编译器就可以消除第一个操作,这意味着如果我们在源代码中有这样一段:
auto y = x;
y = x;
x = 10;
x = 20;
编译器可以自行把这段代码视作长成下面这样一般:
auto y = x;
x = 20;
恐怕你会想,谁会撰写执行如此的冗余读取和多余写入的代码(术语是冗余加载和废弃存储)呢?答案是,人类不会直接撰写如此代码,至少我们希望没人会这样做吧。但是,即使编译器接受的是看上去合情合理的源代码,对其执行模板实例化、内联以及各种常见的重新排序等优化后,结果中包含编译器能够消除的冗余加载和废弃存储的情况并不罕见。
特种内存
此类优化仅在内存行为符合常规时才合法。“特种”内存就是另一回事。
可能最常见的特种内存是用于内存映射IO的内存。这种内存的位置实际上是用于与外部设备(例如,外部传感器、显示器、打印机和网络端口等)通信,而非用于读取或写入常规内存(即RAM)。在此情况下,再次考虑看似冗余的代码:
auto y = x;
y = x;
如果x 对应于,比如说,由温度传感器报告的值,则x 的第二次读取操作并非多余,因为在第一次和第二次读取之间,温度可能已经改变。
看似多余的写入操作也有类似的情形。比如,在这段代码中:
x = 10;
x = 20;
如果x 对应于无线发射器的控制端口,则可能是代码在向无线电发出指令,并且值10 对应于与值20 不同的命令。如果把第一个赋值优化掉,就将改变发送到无线电的命令序列了。
而volatile 的用处就是告诉编译器,正在处理的是特种内存。它的意思是通知编译器“不要对在此内存上的操作做任何优化”。所以,如果x 对应于特种内存,则它应该加上volatile 声明饰词:
volatile int x;
考虑这么一来,会对我们原先的代码序列产生什么影响:
auto y = x;
y = x;
x = 10;
x = 20;
如果x 是内存映射的(或已映射到跨进程共享的内存位置等),这真正是我们想要的效果。
测试时间!在上面最后一段代码中,y应该取什么型别:int 还是volatile int ?
这里需要注意auto 和 cv 的关系,实际上这里的auto 是int ,所以y 的冗余写入会被优化
在处理特种内存时必须保留看似冗余加载和废弃存储这一事实,也顺便解释了为何std::atomic 型别对象不适用于这种工作。编译器可以消除std::atomic 型别上的冗余操作。代码的撰写方式与使用volatile 时不尽相同,但是我们不妨暂时忽略这一点。而先关注编译器允许做的事情,我们可以这么说,从概念上说,编译器可能接受的是这样的代码:
std::atomic<int> x;
auto y = x;
y = x;
x = 10;
x = 20;
并优化成下面这样:
auto y = x;
x = 20;
这显然对于特种内存来说,是不可接受的行为。
无巧不成书,以下两个语句在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());
y.store(x.load());
这段代码可以通过编译,但是,读取x (经由x.load() )是个独立于初始化或存储到y 的函数调用这一事实清楚地表明,没有理由去期望这两条语句中的任何一条可以整体作为单一原子操作执行。
给定上述代码的前提下,编译器可以通过将x 的值存储在寄存器中,而不是两次读取,以“优化”之:
register = x.load();
std::atomic<int> y(register);
y.store(register);
结果正如你所见,x 的读取操作只执行了一次,这是在处理特种内存时必须避免的那种优化(该优化在volatile 变量上不被允许)。
现在事情应该明确了:
std::atomic 对于并发程序设计有用,但不能用于访问特种内存。volatile 对于访问特种内存有用,但不能用于并发程序设计。
由于std::atomic 和volatile 是用于不同目的,他们甚至可以一起使用:
volatile std::atomic<int> val;
如果val 对应于由多个线程同时访问的内存映射IO位置,就可能会是有用的。
3. 小Tips
最后,有些开发人员更喜欢使用std::atomic 的load 和store 成员函数,即使并非必要,因为这样做可以在源代码中明确地表明所涉及的变量并非“常规”。强调这一事实,也并非没有理由。访问std::atomic 型别对象通畅比访问非std::atomic 型别对象慢得多,我们已经看到std::atomic 型别对象在使用过程中会阻止编译器对某些类型的代码重新排序,而这样的重新排序在其他情况下是被允许的。召唤std::atomic 型别对象的加载和存储有助于识别出阻碍潜在的可伸缩性之处。从正确性角度来看,如果本来想要通过某个变量将信息传达到其他线程,却未见它调用store (例如,一个指示数据可用性的标志位),就可能意味着该变量本来应该声明为std::atomic ,却没有这么做。
这在很大程度上是一个代码风格的问题,因此,这与在std::atomic 和volatile 之间进行的选择有着非常不同的性质。
要点速记 |
---|
1. std::atomic 用于多线程访问的数据,且不用互斥量。它是撰写并发软件的工具。 | 2. volatile 用于读写操作不可以被优化掉的内存。它是在面对特种内存时使用的工具。 |
|