40.对并发应用std::atomic,对special memory使用volatile

前言

 
一般而言,volatile与C++并发编程毫无关联。在别的语言中(例如Java或C#),volatile对于并发编程存在较大帮助,不过时至今日,C++的某些编译器也将其与某些语义相结合应用至并发编程,因此在并发编程一章讨论volatile也情有可原。


atomic

 
在C++11中提供了一个std::atomic类模版,可以具体实例化出int、bool等类型实例,这个实例保证了操作的原子性,可以被其他线程查看到操作后的结果。它的行为类似于应用了mutex,但是性能损耗更小,因为其内部使用了一种特殊的机器指令实现,该模版类的使用实例如下所示:

1
2
3
4
5
std::atomic<int> ai(0); // initialize ai to 0
ai = 10; // atomically set ai to 10
std::cout << ai; // atomically read ai's value
++ai; // atomically increment ai to 11
--ai; // atomically decrement ai to 10

在这些语句的执行期间,其他线程只能读到ai为0、10、11这几种情况,不存在别的可能(假设当前仅有此线程对ai执行修改)。

上述程序有两点值得注意的地方。首先,在std::cout << ai语句中,ai的atomic特性仅仅保证ai的读取是原子的,而不能保证整条语句均具备原子性。在读取ai的值和调用operator<<以将其写入cout之间,可能存在另一个线程已改变了ai的值。 总之,我们需要理解仅在读取ai值时具备原子性。

另外,需要关注的是最后两条语句——aide自增与自减操作,这两条操作均为读-改-写(RMW)行为,但它们均具备原子性。这也是std::atomic的优势所在:一旦构造了一个std::atomic对象,其所有成员函数都将具备原子性。


volatile

 
使用volatile的相应代码在多线程环境下几乎什么都不保证:

1
2
3
4
5
volatile int vi(0); // initialize vi to 0
vi = 10; // set vi to 10
std::cout << vi; // read vi's value
++vi; // increment vi to 11
--vi; // decrement vi to 10

在此代码执行期间,如果其他线程正在读取vi的值,那么将可能获取到任何内容。上述语句直接导致了未定义行为,因为这些语句在修改vi的同时其他线程可能正在读取或写入vi,vi既不具备原子性也不被mutex所保护,这导致了data race。


atomic与volatile比较

RMW

假设现有一个计数器,其值会在多线程内自增,我们首先将其初始化为0:

1
2
std::atomic<int> ac(0); // "atomic counter"
volatile int vc(0); // "volatile counter"

然后在两个同时运行的线程中各自增计数器一次:
1
2
3
/*----- Thread 1 ----- */ /*------- Thread 2 ------- */
++ac; ++ac;
++vc; ++vc;

当两个线程结束时,ac的值必然为2,但vc的值则不能确定。自增需要执行三个步骤:读取值、自增读取到的值、将该值写回。ac的三个步骤均具备原子性,但vc则不然,因此vc的自增步骤可能会交错执行,例如:

  1. 线程1读取vc值,读取结果为0
  2. 线程2读取vc值,读取结果为0
  3. 线程1将读取结果自增为1,将其写入vc
  4. 线程2将读取结果自增为1,将其写入vc

因此vc最终结果为1,即使它被自增了两次。这并非是唯一可能的最终结果,事实上,存在数据竞争的代码,其最终结果不可预料。


信息传递

假设当前存在两个任务,第一个任务将会在某个值计算完成后将“已完成”这一信息传递给第二个任务,在Item39只我们曾经提及这种实例可以通过std::atomic<bool>实现。计算task如下所示:

1
2
3
std::atomic<bool> valAvailable(false);
auto imptValue = computeImportantValue(); // compute value
valAvailable = true; // tell other task it's available

对于阅读上述代码的开发者而言,我们都很明确imptValue赋值应当处于valAvailable的赋值之前,但编译器却不这么想,它们认为二者不具备任何关联性,因而编译器有资格对此进行重排。换而言之,对于赋值序列:
1
2
a = b;
x = y;

编译器可能会将其重排为:
1
2
x = y;
a = b;

即使编译器没有对它们进行重新排序,底层硬件也可能会这样做,因为这种行为可能会优化运行速度。

但std::atomic的出现使得这种重排受到限制,在源代码

1
2
auto imptValue = computeImportantValue(); // compute value
valAvailable = true; // tell other task it's available

atomic不仅仅要求编译器禁止对语句执行重排,它还要求底层硬件不允许对赋值先后顺序执行重排。因此它保证了valAvailable的赋值必然发生在imptValue结束赋值之后。

将valAvailavle声明为volatile则不能保证重排受限:

1
2
3
volatile bool valAvailable(false);
auto imptValue = computeImportantValue();
valAvailable = true; // other threads might see this assignment before the one to imptValue!

因此,别的线程可能会发现在valAvailable在imptValue正确赋值之前便已被赋值。


volatile的用途

 
上述两点——不能保证操作原子性与不能保证重排受限说明了volatile在并发编程中似乎毫无作用,实则不然。volatile目的在于告知编译器它们正在处理行为不正常的内存。

normal memory

“正常”内存的特征是,如果将值写入内存位置,则值将保留在那里,直到它被覆盖为止。因此,如果我有一个int:

1
int x;

并且编译器看到了如下操作:
1
2
auto y = x; // read x
y = x; // read x again

编译器可能会对y的赋值语句视而不见,因为它认为这相对于初始化语句而言是冗余的。
此外,“正常”内存还有一大特征:如果我们将值写入特性位置后再未读取过,然后再次写入该内存位置,则可认为第一次写入从未发生。

综上,对于如下语句:

1
2
3
4
auto y = x; // read x
y = x; // read x again
x = 10; // write x
x = 20; // write x again

编译器可能会将其优化为:
1
2
auto y = x; // read x
x = 20; // write x

你可能会好奇谁会写出这种无聊的程序(技术上我们将其称为redundant load与dead store),事实上开发者确实很少主动撰写类似于上述程序的代码。但在编译器执行模板实例化,内联和各种常见的重新排序优化之后,上述程序俯拾皆是,因此编译器有必要对“正常”内存执行优化。


special memory

特殊内存并不需要上述优化。可能最常见的特殊内存类型是用于内存映射I/O的内存。这种内存可能在与外部设备发生通信,例如外部传感器或显示器,打印机,网络端口等,而不是读取或写入普通存储器(即RAM)。在这种环境下,redundant load程序并非redundant:

1
2
auto y = x; // read x
y = x; // read x again

如果x对应于温度传感器的返回值,则x的第二次读取并不冗余,因为温度可能在第一次和第二次读取之间发生改变。
对于重复写入亦是如此,
1
2
x = 10; // write x
x = 20; // write x again

10与20可能是向外设发送的某种指令序列,盲目对其执行优化可能导致设备行为异常。


volatile与special memory

volatile对编译器的意义是:“不要对这个内存上的操作执行任何优化。”因此,我们应该将位于special memory的data声明为volatile:

1
volatile int x;

如此一来,
1
2
3
4
auto y = x; // read x
y = x; // read x again (can't be optimized away)
x = 10; // write x (can't be optimized away)
x = 20; // write x again

最后我们需要指明的是,y的类型为int,具体原因可见Item2。


atomic在上述环境下的执行

假定当前有代码如下:

1
2
3
4
5
std::atomic<int> x;
auto y = x; // conceptually read x (see below)
y = x; // conceptually read x again (see below)
x = 10; // write x
x = 20; // write x again

前文已知,编译器可能会将其优化为:
1
2
auto y = x; // conceptually read x (see below)
x = 20; // write x

但事实上,如果x是atomic,上述代码无法编译:
1
2
auto y = x; // error!
y = x; // error!

原因在于atomic的copy操作被设为delete。试想一下,如果atomic对象能够拷贝,因为x是atomic,因此y也将被推衍为atomic对象。前文已经描述过,atomic对象的所有操作均具备原子性。但如果需要从x拷贝构造出y,编译器必须生成能够在单一原子操作内读取x与写入y的代码,这无法在底层硬件上实现,因此atomic并不支持copy构造。基于同样的理由,atomic的copy assignment也被禁止使用,因此上述程序中y=x无法编译。此外,移动操作并没有在atomic中显式声明,根据Item17所提及的规则,atomic亦不支持移动操作。

事实上我们可以利用x来初始化y或给y赋值,不过这需要使用atomic的load与store成员函数,load成员函数以原子方式读取std::atomic的值,而store成员函数以原子方式写入atomic。因此atomic对象的初始化与赋值应当被写为:

1
2
std::atomic<int> y(x.load()); // read x
y.store(x.load()); // read x again

当然上述语句也清楚表明它们是复合式语句,并不具备原子性。因此编译器可能会对其执行优化,优化策略是将x的值存入寄存器省得读取其两次:
1
2
3
register = x.load(); // read x into register
std::atomic<int> y(register); // init y with register value
y.store(register); // store register value into y

显然最终我们只对x读取了一次,这在special memory中必须避免。


总结

  1. std::atomic可以在不使用mutex的前提下在并发编程中安全访问数据。
  2. volatile适用于禁止编译器执行优化的内存。