条款四十:对于并发使用std::atomic,对于特殊内存使用volatile

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

可怜的volatile。如此令人迷惑。本不应该出现在本章节,因为它跟并发编程没有关系。但是在其他编程语言中(比如,Java和C#),volatile是有并发含义的,即使在C++中,有些编译器在实现时也将并发的某种含义加入到了volatile关键字中(但仅仅是在用那些编译器时)。因此在此值得讨论下关于volatile关键字的含义以消除异议。

开发者有时会与volatile混淆的特性——本来应该属于本章的那个特性——是std::atomic模板。这种模板的实例化(比如,std::atomic<int>std::atomic<bool>std::atomic<Widget*>等)提供了一种在其他线程看来操作是原子性的的保证(译注:即某些操作是像原子一样的不可分割。)。一旦std::atomic对象被构建,在其上的操作表现得像操作是在互斥锁保护的关键区内,但是通常这些操作是使用特定的机器指令实现,这比锁的实现更高效。

分析如下使用std::atmoic的代码:

std::atomic<int> ai(0);         //初始化ai为0
ai = 10;                        //原子性地设置ai为10
std::cout << ai;                //原子性地读取ai的值
++ai;                           //原子性地递增ai到11
--ai;                           //原子性地递减ai到10

在这些语句执行过程中,其他线程读取ai,只能读取到0,10,11三个值其中一个。没有其他可能(当然,假设只有这个线程会修改ai)。

这个例子中有两点值得注意。首先,在“std::cout << ai;”中,ai是一个std::atomic的事实只保证了对ai的读取是原子的。没有保证整个语句的执行是原子的。在读取ai的时刻与调用operator<<将值写入到标准输出之间,另一个线程可能会修改ai的值。这对于这个语句没有影响,因为intoperator<<是使用int型的传值形参来输出(所以输出的值就是读取到的ai的值),但是重要的是要理解原子性的范围只保证了读取ai是原子性的。

第二点值得注意的是最后两条语句——关于ai的递增递减。他们都是读-改-写(read-modify-write,RMW)操作,它们整体作为原子执行。这是std::atomic类型的最优的特性之一:一旦std::atomic对象被构建,所有成员函数,包括RMW操作,从其他线程来看都是原子性的。

相反,使用volatile在多线程中实际上不保证任何事情:

volatile int vi(0);             //初始化vi为0
vi = 10;                        //设置vi为10 
std::cout << vi;                //读vi的值
++vi;                           //递增vi到11
--vi;                           //递减vi到10

代码的执行过程中,如果其他线程读取vi,可能读到任何值,比如-12,68,4090727——任何值!这份代码有未定义行为,因为这里的语句修改vi,所以如果同时其他线程读取vi,同时存在多个readers和writers读取没有std::atomic或者互斥锁保护的内存,这就是数据竞争的定义。

举一个关于在多线程程序中std::atomicvolatile表现不同的具体例子,考虑这样一个简单的计数器,通过多线程递增。我们把它们初始化为0:

std::atomic<int> ac(0);         //“原子性的计数器”
volatile int vc(0);             //“volatile计数器”

然后我们在两个同时运行的线程中对两个计数器递增:

/*----- Thread 1 ----- */        /*------- Thread 2 ------- */
        ++ac;                              ++ac;
        ++vc;                              ++vc;

当两个线程执行结束时,ac的值(即std::atomic的值)肯定是2,因为每个自增操作都是不可分割的(原子性的)。另一方面,vc的值,不一定是2,因为自增不是原子性的。每个自增操作包括了读取vc的值,增加读取的值,然后将结果写回到vc。这三个操作对于volatile对象不能保证原子执行,所有可能是下面的交叉执行顺序:

  1. Thread1读取vc的值,是0。
  2. Thread2读取vc的值,还是0。
  3. Thread1将读到的0加1,然后写回到vc
  4. Thread2将读到的0加1,然后写回到vc

vc的最后结果是1,即使看起来自增了两次。

不仅只有这一种可能的结果,通常来说vc的最终结果是不可预测的,因为vc会发生数据竞争,对于数据竞争造成未定义行为,标准规定表示编译器生成的代码可能是任何逻辑。当然,编译器不会利用这种行为来作恶。但是它们通常做出一些没有数据竞争的程序中才有效的优化,这些优化在存在数据竞争的程序中会造成异常和不可预测的行为。

RMW操作不是仅有的std::atomic在并发中有效而volatile无效的例子。假定一个任务计算第二个任务需要的一个重要的值。当第一个任务完成计算,必须传递给第二个任务。Item39表明一种使用std::atomic<bool>的方法来使第一个任务通知第二个任务计算完成。计算值的任务的代码如下:

std::atomic<bool> valVailable(false); 
auto imptValue = computeImportantValue();   //计算值
valAvailable = true;                        //告诉另一个任务,值可用了

人类读这份代码,能看到在valAvailable赋值之前对imptValue赋值很关键,但是所有编译器看到的是给相互独立的变量的一对赋值操作。通常来说,编译器会被允许重排这对没有关联的操作。这意味着,给定如下顺序的赋值操作(其中abxy都是互相独立的变量),

a = b;
x = y;

编译器可能重排为如下顺序:

x = y;
a = b;

即使编译器没有重排顺序,底层硬件也可能重排(或者可能使它看起来运行在其他核心上),因为有时这样代码执行更快。

然而,std::atomic会限制这种重排序,并且这样的限制之一是,在源代码中,对std::atomic变量写之前不会有任何操作(或者操作发生在其他核心上)。(这只在std::atomics使用顺序一致性sequential consistency)时成立,对于使用在本书中展示的语法的std::atomic对象,这也是默认的和唯一的一致性模型。C++11也支持带有更灵活的代码重排规则的一致性模型。这样的weak)(亦称松散的relaxed)模型使构建一些软件在某些硬件构架上运行的更快成为可能,但是使用这样的模型产生的软件更加难改正、理解、维护。在使用松散原子性的代码中微小的错误很常见,即使专家也会出错,所以应当尽可能坚持顺序一致性。)这意味对我们的代码,

auto imptValue = computeImportantValue();   //计算值
valAvailable = true;                        //告诉另一个任务,值可用了

编译器不仅要保证imptValuevalAvailable的赋值顺序,还要保证生成的硬件代码不会改变这个顺序。结果就是,将valAvailable声明为std::atomic确保了必要的顺序——其他线程看到的是imptValue值的改变不会晚于valAvailable

valAvailable声明为volatile不能保证上述顺序:

volatile bool valVailable(false); 
auto imptValue = computeImportantValue();
valAvailable = true;                        //其他线程可能看到这个赋值操作早于imptValue的赋值操作

这份代码编译器可能将imptValuevalAvailable赋值顺序对调,如果它们没这么做,可能不能生成机器代码,来阻止底部硬件在其他核心上的代码看到valAvailable更改在imptValue之前。

这两个问题——不保证操作的原子性以及对代码重排顺序没有足够限制——解释了为什么volatile在多线程编程中没用,但是没有解释它应该用在哪。简而言之,它是用来告诉编译器,它们处理的内存有不正常的表现。

“正常”内存应该有这个特性,在写入值之后,这个值会一直保持直到被覆盖。假设有这样一个正常的int

int x;

编译器看到下列的操作序列:

auto y = x;                             //读x
y = x;                                  //再次读x

编译器可通过忽略对y的一次赋值来优化代码,因为有了y初始化,赋值是冗余的。

正常内存还有一个特征,就是如果你写入内存没有读取,再次写入,第一次写就可以被忽略,因为值没被用过。给出下面的代码:

x = 10;                                 //写x
x = 20;                                 //再次写x

编译器可以忽略第一次写入。这意味着如果写在一起:

auto y = x;                             //读x
y = x;                                  //再次读x
x = 10;                                 //写x
x = 20;                                 //再次写x

编译器生成的代码是这样的:

auto y = x;                             //读x
x = 20;                                 //写x

可能你会想谁会写这种重复读写的代码(技术上称为冗余访问redundant loads)和无用存储dead stores)),答案是开发者不会直接写——至少我们不希望开发者这样写。但是在编译器拿到看起来合理的代码,执行了模板实例化,内联和一系列重排序优化之后,结果会出现冗余访问和无用存储,所以编译器需要摆脱这样的情况并不少见。

这种优化仅仅在内存表现正常时有效。“特殊”的内存不行。最常见的“特殊”内存是用来做内存映射I/O的内存。这种内存实际上是与外围设备(比如外部传感器或者显示器,打印机,网络端口)通信,而不是读写通常的内存(比如RAM)。这种情况下,再次考虑这看起来冗余的代码:

auto y = x;                             //读x
y = x;                                  //再次读x

如果x的值是一个温度传感器上报的,第二次对于x的读取就不是多余的,因为温度可能在第一次和第二次读取之间变化。

看起来冗余的写操作也类似。比如在这段代码中:

x = 10;                                 //写x
x = 20;                                 //再次写x

如果x与无线电发射器的控制端口关联,则代码是给无线电发指令,10和20意味着不同的指令。优化掉第一条赋值会改变发送到无线电的指令流。

volatile是告诉编译器我们正在处理特殊内存。意味着告诉编译器“不要对这块内存执行任何优化”。所以如果x对应于特殊内存,应该声明为volatile

volatile int x;

考虑对我们的原始代码序列有何影响:

auto y = x;                             //读x
y = x;                                  //再次读x(不会被优化掉)

x = 10;                                 //写x(不会被优化掉)
x = 20;                                 //再次写x

如果x是内存映射的(或者已经映射到跨进程共享的内存位置等),这正是我们想要的。

突击测试!在最后一段代码中,y是什么类型:int还是volatile int?(y的类型使用auto类型推导,所以使用Item2中的规则。规则上说非引用非指针类型的声明(就是y的情况),constvolatile限定符被拿掉。y的类型因此仅仅是int。这意味着对y的冗余读取和写入可以被消除。在例子中,编译器必须执行对y的初始化和赋值两个语句,因为xvolatile的,所以第二次对x的读取可能会产生一个与第一次不同的值。)

在处理特殊内存时,必须保留看似冗余访问和无用存储的事实,顺便说明了为什么std::atomic不适合这种场景。编译器被允许消除对std::atomic的冗余操作。代码的编写方式与volatile那些不那么相同,但是如果我们暂时忽略它,只关注编译器执行的操作,则概念上可以说,编译器看到这个,

std::atomic<int> x;
auto y = x;                             //概念上会读x(见下)
y = x;                                  //概念上会再次读x(见下)

x = 10;                                 //写x
x = 20;                                 //再次写x

会优化为:

auto y = x;                             //概念上会读x(见下)
x = 20;                                 //写x

对于特殊内存,显然这是不可接受的。

现在,就像下面所发生的,当xstd::atomic时,这两条语句都无法编译通过:

auto y = x;                             //错误
y = x;                                  //错误

这是因为std::atomic类型的拷贝操作是被删除的(参见Item11)。因为有个很好的理由删除。想象一下如果y使用x来初始化会发生什么。因为xstd::atomic类型,y的类型被推导为std::atomic(参见Item2)。我之前说了std::atomic最好的特性之一就是所有成员函数都是原子性的,但是为了使从x拷贝初始化y的过程是原子性的,编译器不得不生成代码,把读取x和写入y放在一个单独的原子性操作中。硬件通常无法做到这一点,因此std::atomic不支持拷贝构造。出于同样的原因,拷贝赋值也被删除了,这也是为什么从x赋值给y也编译失败。(移动操作在std::atomic没有显式声明,因此根据Item17中描述的规则来看,std::atomic不支持移动构造和移动赋值)。

可以将x的值传递给y,但是需要使用std::atomicloadstore成员函数。load函数原子性地读取,store原子性地写入。要使用x初始化y,然后将x的值放入y,代码应该这样写:

std::atomic<int> y(x.load());           //读x
y.store(x.load());                      //再次读x

这可以编译,读取x(通过x.load())是与初始化或者存储到y相独立的函数,这个事实清楚地表明没理由期待上面的任何一个语句会在单独的原子性的操作中整体执行。

给出上面的代码,编译器可以通过存储x的值到寄存器代替读取两次来“优化”:

register = x.load();                    //把x读到寄存器
std::atomic<int> y(register);           //使用寄存器值初始化y
y.store(register);                      //把寄存器值存储到y

结果如你所见,仅读取x一次,这是对于特殊内存必须避免的优化。(这种优化是不允许对volatile类型变量执行的。)

因此情况很明显:

  • std::atomic用在并发编程中,对访问特殊内存没用。
  • volatile用于访问特殊内存,对并发编程没用。

因为std::atomicvolatile用于不同的目的,所以可以结合起来使用:

volatile std::atomic<int> vai;          //对vai的操作是原子性的,且不能被优化掉

如果vai变量关联了内存映射I/O的位置,被多个线程并发访问,这会很有用。

最后一点,一些开发者在即使不必要时也尤其喜欢使用std::atomicloadstore函数,因为这在代码中显式表明了这个变量不“正常”。强调这一事实并非没有道理。因为访问std::atomic确实会比non-std::atomic更慢一些,我们也看到了std::atomic会阻止编译器对代码执行一些特定的,本应被允许的顺序重排。调用loadstore可以帮助识别潜在的可扩展性瓶颈。从正确性的角度来看,没有看到在一个变量上调用store来与其他线程进行通信(比如用个flag表示数据的可用性)可能意味着该变量在声明时本应使用而没有使用std::atomic

这更多是习惯问题,但是,一定要知道atomicvolatile的巨大不同。

请记住:

  • std::atomic用于在不使用互斥锁情况下,来使变量被多个线程访问的情况。是用来编写并发程序的一个工具。
  • volatile用在读取和写入不应被优化掉的内存上。是用来处理特殊内存的一个工具。