其他编程语言中(Java和C#),volatile
是有并发含义的,即使在C++中,有些编译器在实现时也将并发的某种含义加入到了volatile
关键字中。因此讨论volatile
关键字的含义以消除异议。
atomic
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为10std::cout << ai; //原子性地读取ai的值
++ai; //原子性地递增ai到11
--ai; //原子性地递减ai到10
在这些语句执行过程中,其他线程读取ai
,只能读取到0,10,11三个值其中一个。
这个例子中有两点值得注意。首先,在“std::cout << ai;
”中,ai
是一个std::atomic
的事实只保证了对ai
的读取是原子的。没有保证整个语句的执行是原子的。在读取ai
的时刻与调用operator<<
将值写入到标准输出之间,另一个线程可能会修改ai
的值。这对于这个语句没有影响,因为int
的operator<<
是使用int
型的传值形参来输出(所以输出的值就是读取到的ai
的值),但是重要的是要理解原子性的范围只保证了读取ai
是原子性的。
ai
的递增递减都是读-改-写(read-modify-writeRMW)操作,它们整体作为原子执行。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,409072,任何值。这份代码有未定义行为,因为这里的语句修改vi
,所以如果同时其他线程读取vi
,同时存在多个readers和writers读取没有std::atomic
或者互斥锁保护的内存,这就是数据竞争的定义。
举一个关于在多线程程序中std::atomic
和volatile
表现不同的具体例子,考虑这样一个简单的计数器,通过多线程递增。把它们初始化为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
对象不能保证原子执行,所有可能是下面的交叉执行顺序:
- Thread1读取
vc
的值,是0。 - Thread2读取
vc
的值,还是0。 - Thread1将读到的0加1,然后写回到
vc
。 - 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
赋值很关键,但是所有编译器看到的是给相互独立的变量的一对赋值操作。通常来说,编译器会被允许重排这对没有关联的操作。这意味着,给定如下顺序的赋值操作(其中a
,b
,x
,y
都是互相独立的变量),
a = b;
x = y;
编译器可能重排为如下顺序:
x = y;
a = b;
即使编译器没有重排顺序,底层硬件也可能重排(或者可能使它看起来运行在其他核心上),因为有时这样代码执行更快。std::atomic
会限制这种重排序,并且这样的限制之一是,在源代码中,对std::atomic
变量写之前不会有任何操作。(这只在std::atomic
使用顺序一致性时成立,对于使用在本书中展示的语法的std::atomic
对象,这也是默认的和唯一的一致性模型。C++11也支持带有更灵活的代码重排规则的一致性模型。这样的弱(weak)模型使构建一些软件在某些硬件构架上运行的更快成为可能,但是使用这样的模型产生的软件更加难改正、理解、维护。在使用松散原子性的代码中微小的错误很常见,即使专家也会出错,所以应当尽可能坚持顺序一致性。)
auto imptValue = computeImportantValue(); //计算值
valAvailable = true; //告诉另一个任务,值可用了
编译器不仅要保证imptValue
和valAvailable
的赋值顺序,还要保证生成的硬件代码不会改变这个顺序。结果就是,将valAvailable
声明为std::atomic
确保了必要的顺序——其他线程看到的是imptValue
值的改变不会晚于valAvailable
。将valAvailable
声明为volatile
不能保证上述顺序:
volatile bool valVailable(false);
auto imptValue = computeImportantValue();
val Available = true;
//其他线程可能看到这个赋值操作早于imptValue的赋值操作
这份代码编译器可能将imptValue
和valAvailable
赋值顺序对调,如果它们没这么做,可能不能生成机器代码,来阻止底部硬件在其他核心上的代码看到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
的情况),const
和volatile
限定符被拿掉。y
的类型因此仅仅是int
。这意味着对y
的冗余读取和写入可以被消除。在例子中,编译器必须执行对y
的初始化和赋值两个语句,因为x
是volatile
的,所以第二次对x
的读取可能会产生一个与第一次不同的值)
在处理特殊内存时,必须保留看似冗余访问和无用存储的事实,顺便说明了为什么std::atomic
不适合这种场景。编译器被允许消除对std::atomic
的冗余操作。代码的编写方式与volatile
那些不那么相同,但是如果我们暂时忽略它,只关注编译器执行的操作,则概念上可以说,编译器看到这个,
Volatile
volatile关键字用于告诉编译器某个变量可能会被外部因素(如硬件中断或并发线程)改变,因此每次访问该变量时都必须实际读取或写入内存,而不是从寄存器或缓存中获取值。这防止了编译器优化掉看似冗余的读写操作,确保了对特殊内存位置(例如I/O端口、硬件寄存器等)的正确处理。
volatile int x;
auto y = x; // 读x
y = x; // 再次读x,不会被优化掉
x = 10; // 写x,不会被优化掉
x = 20; // 再次写x
由于auto推导出的y是一个普通的int类型,它不受volatile的影响,因此对y的操作可以被优化。但是,因为x是volatile的,所以所有对x的访问都不能被优化。
然而,std::atomic并不阻止编译器进行某些类型的优化,比如合并连续的读操作。此外,std::atomic不允许拷贝构造或赋值,以防止无法实现真正的原子性操作。因此,要与普通变量交互,需要使用load()和store()方法:
std::atomic<int> x;
int y = x.load(); // 读x
x.store(10); // 写x
结合使用 volatile 和 std::atomic
对于既需要原子性又需要防止编译器优化的情况(例如访问共享内存中的硬件寄存器),可以将两者结合起来使用:
volatile std::atomic<int> vai; // 对vai的操作是原子性的,且不能被优化
这样定义的变量既可以保证线程安全,也可以确保所有访问都不会被编译器优化掉。
因此情况很明显:
std::atomic
用在并发编程中,对访问特殊内存没用。volatile
用于访问特殊内存,对并发编程没用。
因为std::atomic
和volatile
用于不同的目的,所以可以结合起来使用:
volatilestd::atomic<int> vai; //对vai的操作是原子性的,且不能被优化掉
请记住:
std::atomic
用于在不使用互斥锁情况下,来使变量被多个线程访问的情况。是用来编写并发程序的一个工具。volatile
用在读取和写入不应被优化掉的内存上。是用来处理特殊内存的一个工具。