Item 40: Use std::atomic for concurrency, volatile for special memory.
本 Item 探讨一下 atomic
类型和 volatile
关键字在并发程序中的区别和应用。
C++11 提供了 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
,不会有其他的值。这里面有两个方面值得注意:
- 其一,
std::cout << ai
这整个操作不是原子的,只能保证ai
的读取是原子的,不能保证整个语句是原子的,也就是说在ai
读取后和写到标准输出之间的时刻,ai
的值可以被其他线程修改。不过,也不影响到ai
的输出值,因为operator<<
是值拷贝的。 - 其二,对于最后两条语句(
++ai
、--ai
),它们都是 read-modify-write(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<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
和 atomic
类型的唯一区别。考虑这样一个场景:当一个线程完成一个重要计算后,通知另外一个线程。Item 39: Consider void futures for one-shot event communication. 讨论这一场景的方案。这里,我们使用 atomic
变量通信。代码类似如下:
std::atomic<bool> valAvailable(false);
auto imptValue = computeImportantValue(); // compute value
valAvailable = true; // tell other task it's available
从代码顺序上看,imptValue
的赋值发生在 valAvailable
赋值之前。但事实并未一定如此,编译器可以对改变二者的执行顺序以提高性能,例如:
a = b;
x = y;
因为两个赋值语句不互相依赖,编译器可以重排序如下:
x = y;
a = b;
即使编译器不重排序,底层的硬件也可能做重排序。
但是 std::atomic
的使用禁止了编译器和底层硬件对这段代码的重排序,这种行为称为顺序一致性模型。而 volatile
无法阻止这种重排序。
volatile
无法保证操作的原子性和无法阻止指令的重排序,这就导致了它在并发编程中很少使用,那么 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
而对于:
std::atomic<int> x;
auto y = x; // error!
y = x; // error!
实际上无法编译的,因为 std::atomic
的拷贝操作是被 deleted
的。std::atomic
的成员函数 load
和 store
可以提供这样的功能:
td::atomic<int> y(x.load()); // read x
y.store(x.load()); // read x again
对于上述代码,编译器可能优化为:
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
可以将二者结合起来使用。例如:
volatile std::atomic<int> vai; // operations on vai are
// atomic and can't be
// optimized away
可以用于 memory-mapped I/O 内存,并被多线程访问。
参考: