Item 40: Use std::atomic for concurrency, volatile for special memory

在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::atomicvolatile 的区别,考虑如下代码:

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. 线程1 读取 vc 的值,为 0。
  2. 线程2 读取 vc 的值,仍然为 0。
  3. 线程1 将读取的 vc 值从增加到 1,然后写进 vc 的内存。
  4. 线程2 将读取的 vc 值从增加到 1,然后写进 vc 的内存。

这么一来,vc 的值会是1,即使它自增了两次。这不是唯一可能的结果,一般来说,vc的取值是无法预测的,这是一种未定义的行为。

这种 RMW 行为的原子性并不是 volatilestd::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 的成员函数 loadstore 可以提供这样的功能:

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 用于读写操作不可以被优化掉的内存。它是处理特殊内存时使用的工具;
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值