在C++ 中,volatile 跟并发一点关系都没有。之所以放在这里讨论,是因为它被太多人误解了。
C++11 提供了 std::atomic 类模版,可以保证操作的原子性,确保其他线程看到的肯定是操作后的结果。其内部使用特殊的机器指令实现,这些指令比互斥量更高效。
考虑下面的使用 std::atomic 的场景:
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,不会有其他的值。有两处需要特殊说明一下:
- 1,“std::cout << ai;”这句代码,只能保证 ai 的读取是原子的,不能保证整个语句是原子的,也就是说在 ai 读取后和写到标准输出之间的时刻,ai 的值可以被其他线程修改。不过这不影响 ai 的输出值,因为 operator<< 是值拷贝的,因此输出的值会是从 ai 读取的值;
- 2, 对于最后两条语句(++ai、–ai),它们都是 RMW(read-modify-write)类型操作,但都是以原子方式执行的。这是 std::atomic类型最棒的特性之一:即,一旦构造出 std::atomic对象,其上所有的成员函数(包括那些RMW操作的成员函数)都保证被其他线程视为原子的;
作为对比,volatile 在多线程语境中,提供不了任何原子保证:
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 的值可能是任一值,比如 -12、68、4090727,这是一种位定义的行为。
为了比较 std::atomic 和 volatile 的区别,考虑如下代码:
std::atomic<int> ac(0); // "atomic counter"
volatile int vc(0); // "volatile counter"
// Thread 1
++ac;
++vc;
// Thread 2
++ac;
++vc;
当两个线程执行完,ac的值肯定是2(因为 ac 的 RMW 过程是保证原子性的)。而vc的值却不一定是2,因为vc的 RMW 过程可以是交替进行的,例如:
- 线程1 读取 vc 的值,为 0。
- 线程2 读取 vc 的值,仍然为 0。
- 线程1 将读取的 vc 值从增加到 1,然后写进 vc 的内存。
- 线程2 将读取的 vc 值从增加到 1,然后写进 vc 的内存。
这么一来,vc 的值会是1,即使它自增了两次。这不是唯一可能的结果,一般来说,vc的取值是无法预测的,这是一种未定义的行为。
这种 RMW 行为的原子性并不是 volatile 和 std::atomic 的唯一区别。考虑这样一个场景:当一个线程完成一个重要计算后,通知另外一个线程。Item 39 讨论过这一场景的方案。这里,我们使用 atomic 变量通信。代码如下:
std::atomic<bool> valAvailable(false);
auto imptValue = computeImportantValue(); // compute value
valAvailable = true; // tell other task it's available
我们已经知道,上面的代码一定会按顺序执行。
通常,编译器可以将不相关的赋值操作重新排序,比如:
a = b;
x = y;
因为两个赋值语句不互相依赖,编译器可以重排序如下:
x = y;
a = b;
即使编译器不重排序,底层的硬件也可能做重排序。因为这样做有时会使代码运行的更快。
但是 std::atomic 的使用禁止了编译器和底层硬件对上述代码的重排,这种行为称为顺序一致性模型。而 volatile 无法阻止这种重排:
volatile bool valAvailable(false);
auto imptValue = computeImportantValue();
valAvailable = true; // other threads might see this assignment
// before the one to imptValue!
上面的代码,编译器可能会将赋值顺序反转为先imptValue后valAvailable,即使编译器不会这么做,硬件也可能这么做。
综上,“无法保证操作的原子性” + “无法对代码重排施加限制”解释了为啥 volatile 对并发编程没用。
那么,volatile 有什么用呢?它的用处就是告诉编译器,正在处理的内存不太“正常”。“正常”的内存有这样的特点:将一个值写入内存,这个值保持不变,直到它被改写。例如:
auto y = x; // read x
y = x; // read x again
上面的代码中,多次读取 x 的值,编译器可以这样优化:会将 x 的值放在寄存器中,再读取 x 的值时,直接从寄存器中读取即可。
对于写内存,编译器也会做优化。例如:
x = 10; // write x
x = 20; // write x again
编译器会进行优化:只执行了 x = 12 条语句,而删除 x = 10 这条语句。
上述的优化对于“正常”行为的内存是适用的,但对于特殊的内存并不适用。最常见的这种特殊内存用于 memory-mapped I/O,这种内存用于和外设通信:
auto y = x; // read x
y = x; // read x again
这样的两次写内存都会对外设产生影响。例如外设根据该内存的值显示波形,那么上述多条写内存的操作就不是冗余的。对于这种情况,必须使用 volatile 来告诉编译器禁止对变量的读写进行优化。例如:
volatile int x;
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
而 std::atomic 无法做到这一点。例如:
std::atomic<int> x;
x = 10; // write x
x = 20; // write x again
上面的代码,可能被编译器优化为:
std::atomic<int> x;
x = 20; // write x
对于某些共享内存,或者IO映射内存,这当然是不可接受的!
有一点需要说明一下, std::atomic的拷贝操作都被delete(see Item 11)了。
所以,这样的代码是无法通过编译的:
std::atomic<int> x;
auto y = x; // error!
y = x; // error!
std::atomic 的成员函数 load 和 store 可以提供这样的功能:
td::atomic<int> y(x.load()); // read x
y.store(x.load()); // read x again
但其实,编译器可以通过将 x 的值存储在寄存器中,而不是两次读取,所以编译可能将代码优化为:
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
所以:
- std::atomic 对于并发编程是有用的,但不能用于访问特殊内存;
- volatile 对于访问特殊内存有用,但不能用于并发程序设计;
由于二者用于不同目的,它们是可以结合起来使用的:
volatile std::atomic<int> vai; // operations on vai are
// atomic and can't be
// optimized away
如果 vai 对应于一个由多线程访问的内存映射I/O,那么这将非常有用。
Things to Remember
- std::atomic 用于多线程访问的数据,且不用互斥量。它是编写并发软件的工具;
- volatile 用于读写操作不可以被优化掉的内存。它是处理特殊内存时使用的工具;