可怜的volatile,被误解如此之深,就不应该出现在本章,因为它和并发编程毫无关系。但在其他语言(java或者c#),它在并发编程中很用。在某些c++编辑器中,volatile被赋予了特殊语义,使其可以用在并发编程中(但也只有用这些编译器编译才可以用)。即使为了澄清围绕它产生的误解,在关于并发编程的本章中探讨一下volatiile也是有价值的。
c++容易混淆属于volatile和std::atoimic模板的特征。模板的实例化(如std::atoimic<int> std::atomic<bool>和std::atomic<Widget*>)提供了不同线程之间的原子操作。一旦std::atomic对象构建出来,对其访问,就如同在mutex保护的关键代码中访问一样。然而这些操作由特殊的机器指令来实现,比使用mutex保护的效率要高很多。
考虑下面使用std::atomic的代码:
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,10)。别的值都不可能出现(当然我们假定了只有一个线程更改)
上面例子中两个语句值得一提。一个是std::cout << ai。因为事实上std::atomic只保证读取ai是原子操作,并不保证整条语句都是原子的。在读取ai和<<操作输出到标准输出之间,别的线程可能会更改ai的值。这对输出无影响,因为int 的<<操作是值传递(所以智慧输出读取到的值)。但重要的是要理解,这条语句中的原子操作不过是单指读取ai。
另外一个值得一提的是最后两个语句的行为——自增和自减。它们每个都是读改写(RMW)操作,因此它们进行原子操作。这是std::atomic类型最优雅之处,一旦一个std::atomic对象创建出来,所有成员函数操作,包括自增和自减之类的(RMW)都是原子操作。
相反,多线程上下文使用volatile什么都不会保证
volatile int vi(0);//初始化为0
vi=10; //设置vi为10
std::cout << vi; // 读取vi值
++vi; //自增
--vi; //自减
如果此时别的线程在读取vi,它们可以读取到任何值(比如-12,68,4090727)。这些代码会引起未定义行为,因为这些语句在更改vi时,别的线程可能正在读取ai。同时读写一块内存,而这块内存既没有定义成std:;atomic的,又没有被mutex保护,这就是数据竞争的定义。
为了比较std::atomic和volatile在多线程下行为差异,考虑多线程下的这两种类型计数器。我们都初始化为0:
std::atomic<int> ac(0); //原子类型计数器
volatile int vc(0); // volatile类型计数器
然后我们将在两个线程中,对其自增:
thread1: thread2:
++ac; ++ac;
++vc; ++vc;
操作完成后,ac结果为2,而vc的结果不一定为2. 因为vc的增加不是原子操作,它包含了读取,更改和写入操作。
可能有下面一种时序:
1.线程1读取vc为0
2.线程2读取vc为0
3.线程1增加vc为1,写入1
4线程2增加vc为1, 写入1
vc最终结果可能为1,尽管它看起来被增加了2次。
这并不是唯一的结果。因为vc涉及数据竞争,而标准说数据竞争会出现未定义行为,所以编译器按字面意思可以生成出代码做出任何行为。编译器当然不会恶意使用这个自由。它们只是会对代码进行优化,这种优化在没有数据竞争情况下是合法的,但在数据竞争情况下则会引起未定义行为。
std::atoimic会成功,volatile会失败,RMW并不是唯一的操作事例。设想第一个线程计算得到一个重要的数值,第二个线程需要得到这个数值。根据条款39,可以使用std::atomic<bool> 来传递。
std::atomic<bool> valAviable{false}; //初始化false
auto importantValue = computeValue(); //计算
importantValue = true;
在人读到这些代码时,我们知道确保importantValue 在importantValue 赋值之前赋值很关键。但在计算机看来,这是两个独立的赋值语句。通常会允许编译器调整两个无关语句顺序。这意味着,下面的顺序(用a,b,x,y表示无关变量)
a=b;
x=y;
编译器可以调整为:
x=y;
a=b;
即使编译器不进行重排,底层的硬件也可能会去重排,因为这可以加快代码运行速度。
而使用std::atomic后,编译器会按照代码重排顺序插入额外指令,确保了源代码中std::atomic的写入不会被放在后面进行。
这意味着我们代码中:
auto importantValue = computeValue(); //计算
importantValue = true;
不仅编译器要保持这两个变量的赋值顺序,而且底层硬件也要保持。std::atomic最终确保了importantValue的改变 对线程的可见性,不晚于importantValue 。
声明volatile,并不会带来代码重排限制。
volatile importantValue (false);
auto importantValue = computeValue(); //计算
importantValue = true; //别的线程可能会先看到这条赋值语句,而后看到importantValue语句。即使编译器不这么做,硬件和别的核心也可能会先看到importantValue 的改变。
不保证操作原子性,以及不充分的代码重排限制,这两点解释了volatile不适合并发编程领域原因,但也没解释它适合什么领域。概括的说,它告诉编译器,正在操作的内存其行为不同寻常。
正常的内存一旦写入数据,将会一直保持,直到有人去在同一个位置写入另外的内容。我有一个正常内存的int,
int x;
编译器看到下面代码:
auto y = x // 读取x
y=x; //再次读取x
编译器会优化上面代码,省略y=x,因为和前面初始化形成了冗余。
正常内存还有另外一个特征,如果你写入变量一个值,不去读取它,又写入一个值,那么编译器会忽略第一个写入语句,因为从来没有使用。有下面相邻语句:
x=10; // write x
x=20; // write x again
如果有下列语句:
auto y =x; //read x
y=x; // read x again
x=10; // write x
x=20; // write x again
编译器优化以后,看起来会是这样:
auto y=x; //read x
x=20; //write x
你会想知道谁会写出这样冗余的读写操作代码,答案是人类不会直接写出——至少我们不希望写出。然而编译器检查完源代码,进行模板实例化,以及进行许多常规优化之后,就可能会出现冗余的读写操作。
只有在内存是正常内存前提下,优化才是合法的。如果是特殊的内存,则是非法的。可能最常见的特殊内存是内存映射I/O. 访问这些内存,实际上是与外设(外部寄存器,显示器,打印机和网络端口),而不是读写RAM。在这样上下文中,重新考虑这似乎冗余的代码:
auto y =x; //读取x
y = x; //再次读取x
如果x与温度寄存器关联,那么第二次读取就不是冗余读取,因为在两次读取之间,寄存器的数值可能会变化。
显得冗余的写,也是同样情况,如下面代码:
x=10;//第一次写
x=20;//第二次写
如果x与一个无线发射器的端口关联,那么代码可能是在给发射器发送命令。10和20都代表不同的命令,将第一个命令优化掉,就改变了命令序列。
通过volatile我们就告知编译器在处理一种特殊的内存。编译器理解的是:不要对代码的内存操作做任何优化。如果x代表特殊内存,可以这样来定义:
volatile int x
考虑到对我们代码影响:
auto y=x; //读取x
y=x;//再次读取x 不能优化
x=10 // 写入10 不能优化
x=20;//再次写入
这就是当x被内存映射时,我们确切想要的。
小测验,猜一猜y的类型是什么,volatile int还是int?
对特殊内存进行的似乎冗余的操作,解释了std::atomic不适合这类工作。编译器允许对std::atomic类型的冗余操作优化。
std::atomic<int> x;
auto y = x; //概念上读取
y=x; // 概念上再次读
x=10;//写入
x=20;//再次写入
优化后:
auto y = x // 概念上读取x
x=20;//写入x
对于特殊内存来说,很明显这是不可接受的。
现在,当x是atomic类型时,两条语句都不能编译通过:
auto y=x / /error
y=x //error
这是因为std::atomic类型的拷贝构造函数是delete类型(条款11),这也有很好的理由。如果y=x能通过编译,想想会发生什么。因为x是atomic类型,那么y的类型可以推定同为atomic。我说过atomic的最好之处在于所有操作都是原子的。为了使拷贝也是原子的,编译器必须生成一条原子的指令完成读取x和写入y。硬件无法实现这个操作,所以原子类型不支持拷贝操作。同样的原因,拷贝赋值也不能编译通过。(还有move拷贝和move赋值)
从x读取数值到y是可以的,但需要用std::atomic的成员函数load 和store。load原子读取,而store原子写入。将x值赋值给y,必须这样写:
std::atomic<int> y(x.load()) //readx
y.store(x.load());// readx again
读取x和初始化y,写入y是分离的调用,所以它们整体是原子操作。
编译器使用寄存器来优化读取,避免读取两次:
register = x.load();
std::atomic<int> y(register)
y=register;
结果是只对x读取1次,对于特殊内存来说,应该要避免的。(对于volatile类型,这种优化是禁止的)
因为atomic和volatile各自有各自用途,所以二者可以一起使用:
volatile std::atomic<int> val; //val的操作都是原子的,并且不能优化掉
这在并发访问内存映射I/O时候有用。
最后说一点,有些开发者喜欢使用std::atomic 的load和store成员函数,即使这种使用是非必须的。原因是可以在源代码表明这写变量是一种非常规的。强调这一事实并不无道理。访问一个atomic类型的变量通常要比非stomic类型的变量慢很多。而且我们知道使用atomic类型变量,会阻止一些种类的代码重排序,而这些操作在不使用atomic情况下是允许的。使用load和store有助于识别影响规模的瓶颈,从正确的角度来看,不在同其他线程的通信(比如一个标志数据准备好的变量)变量上使用store,意味着变量没有被定义成std::atomic,而本来应该定义成的。
这很大程度上是个风格问题,和在std::atomic和volatile之间做出选择有着极大的不同。
注意事项
std::atomic用于不同线程之间在不用mutex下数据的并发访问。是并发编程的一个工具
volatile用于对内存的读写操作不应优化掉的情况。是与操纵某些特殊内存的工具。