跳转至

Item40 Use std::atomic for concurrency, volatile for specific memory

   volatile关键字在C++中很少被使用,更准确来说是很少被正确使用,它的用途令人很迷惑,甚至这个关键字都不会出现在并发章节。因为这个关键字对于并发编程来说没有任何用处,但是在其他编程语言中这个关键字的用途却很大,因此很值得在本文去探讨一下volatile关键字,排除读者们对volatile关键字的困扰。

推荐一下何登成大神的一篇关于volatile关键字的博客C/C++ Volatile关键词深度剖析

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

1
2
3
4
5
std::atomic<int> ai(0);
ai = 10;            // 原子的设置ai的值为10
std::cout << ai;    // 原子的读取ai的值,但是std::cout输出的动作并不是原子的
++ai;   // 原子的,递增到11
--ai;   // 原子的,递减到10

   在执行上面的这些操作时,其他线程在任何时间都可以看到ai的最新值,可能是0、10、11不会看到其他的中间值。上面的这段代码中有两点值得细究,第一个就是std::cout << ai,这个语句本身并不是原子的,读取ai的值,这本身是原子的,但是将ai的值输出则不是原子的,在输出的时候其他线程可以改变ai的值。第二个方面是++ai--ai这两个操作,这两个是操作是RMW(Read-Modtify-Write)类型的操作,这也是原子的,这得益于std::atomic类所提供的特性。

​   volatile则相反,使用volatile修饰的变量其操作并不是原子的,其他线程可能会读取到中间值,volatile的基本使用如下:

1
2
3
4
5
volatile int vi(0);
vi = 10;
std::cout << vi;
++vi;
--vi;

​   在上面的代码执行过程中,其他线程会去读vi的值时可能会出现任意值,这是一种未定义行为。 为了更一步分析std::atomicvolatile两者行为的不同,下面举一个具体的例子:

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

有两个线程同时执行下面两个操作:

1
2
3
4
5
6
7
// 线程1
++ac;
++vc

// 线程2
++ac;
++vc;

​   当两个线程执行完成后,ac的值肯定是2,而vc的值则不一定,volatile不保证vc的最后值是2,它可能是0,也有可能是1,下面让我们来具体分析一下:

  1. 线程1读取vc的值,是0
  2. 线程2读取vc的值,仍然是0
  3. 线程1增加读取vc的值为为0,然后递增,最后将递增后的值写入到vc
  4. 线程1增加读取vc的值为为0,然后递增,最后将递增后的值写入到vc

   上面这种情况,vc的值最后是1,进行了两次递增,但是递增的结果是想同的,因为线程1和2看到了vc的值是想同的,对于这种行为,我们称之为存在data race(数据竞争),是一个未定义的行为。需要使用mutex,或者是原子操作来避免这种未定义行为的出现。

   RWN这种操作的原子性并不是std::atomicvolatile两者的唯一一个区别,考虑另外一个场景,当一个线程完成一个重要计算后,通知另外一个线程,很明显这个场景很适合我们在Item39中提到的方案来解决,不过在这里使用std::atomic来解决这件事,通过使用std::atomic<bool>作为一个flag进行通知,部分代码如下:

1
2
3
std::atomic<bool> valAvailabel(false);
auto imptValue = computeImportantValue();
valAvailabel = true;

   一眼看上去,valAvailabel的赋值是在imptValue赋值之前发生的,但是实际上并不一定是这样的,编译器可能会对这两个赋值语句进行重排序,即使编译器没有做这样的工作,硬件也可能会对这两个操作进行指令级别的重排,对于给定如下的顺序:

1
2
a = b;
x = y;

   因为a,b和x,y互相不产生依赖,所以编译器可能会进行重排,重排后的顺序如下:

1
2
x = y;
a = b;

   这种重排序的目的是为了运行的更快,无论是在编译器层面的重排序,还是在CPU指令集层面的重排,然后这一切都被std::atomic屏蔽了,默认情况下std::atomic禁止了底层编译器和硬件的重排序。这种行为称为顺序一致性模型,std::atomic也支持更加复杂的内存模型,比如松散模型,这种模型下可以是的代码运行的更快。相反的是volatile无法阻止这种重排序的发生。综上所述,volatile存在两个问题,第一个就是原子性,第二个就是重排序的问题。这也就解释了为何volatile在并发编程领域中几乎没有任何价值。

   既然volatile在并发编程领域几乎没有任务价值,那么volatile存在的意义是什么呢?首先我们来看下面这段代码:

1
2
3
int x = 10;
auto y = x;
std::cout << x;

   上面的代码中,多次读取x的值,编译器为了优化会将x的值放在寄存器中,每次后面读取x的值时,直接从寄存器返回即可。同理对于多次写一个内存位置的情况,编译器也会做优化,代码如下:

1
2
x = 10;
x = 12;

   编译器会进行优化,实际上只执行了x = 12这次操作,省略了x = 10这一步。这些优化加速了程序的运行速度,但是如果是在一些特殊的设备上进行这样的操作就会导致不符合预期的效果,我们都知道一些外部设备的访问其实是可以通过访问内存的形式来访问,对于这些设备来说每一次访问都会让设备产生一定的效果,是不能省略掉的。就好比x = 10; x = 12来说,对于某些设备来说这可能是一个渐变的效果,如果省去了x = 10那么这个效果就大打折扣了。 为此对于这种情况来说必需使用volatile来告诉编译器禁止对变量的读写进行优化。std::atomic无法做到这一点,它只保证了操作的原子性,编译器仍然会多次冗余的读写操作进行优化。