1 std::atomic和volatile

  1. 在C++中,volatile与并发编程没有任何关系。
  2. std::atomic 模板的实例(例如,std::atomic、std::atomic<Widget*>等)提供了其他线程保证看到的原子操作。这些操作通常是使用特殊的机器指令实现的,这些指令比使用互斥锁更高效。
std::atomic<int> ai(0); 	// 初始化 ai 为 0
ai = 10; 			// 原子地将 ai 设置为 10
std::cout << ai; 		// 原子地读取 ai 的值
++ai; 				// 原子地将 ai 递增到 11
--ai; 				// 原子地将 ai 减到 10

1)在执行这些语句时,其他线程读取ai可能只看到值0、10或11。没有其他值是可能的。
2)在“std::cout << ai;”语句中,ai是std::atomic的事实只保证了对ai的读取是原子的。不能保证整个语句是原子执行的。这对该语句的行为没有影响,因为operator<<用于整数的输出参数是按值传递的。
3)对ai的递增和递减操作都是读-修改-写(RMW)操作,但它们是原子执行的。一旦构造了std::atomic对象,它的所有成员函数(包括构成RMW操作的成员函数)都保证被其他线程视为原子操作。

  1. 与此相反,volatile修饰的变量在多线程环境下它并不能保证线程安全。
//其他线程正在读取vi的值,可能会看到任何值,比如-12、68、4090727等等。因为这些语句修改vi的值的同时其他线程可能同时读取vi
volatile int vi(0); 	// 初始化 vi 为 0
vi = 10; 		// 设置 vi 为 10
std::cout << vi; 	// 读取 vi 的值
++vi; 			// 将 vi 递增到 11
--vi; 			// 将 vi 减到 10
  1. 考虑一个简单的计数器,它会被多个线程递增。将分别初始化一个std::atomic类型的计数器ac和一个volatile int类型的计数器vc,初始值都为0:
std::atomic<int> ac(0); 	// “atomic 计数器"
volatile int vc(0); 		// "volatile 计数器"

然后,在两个同时运行的线程中增加每个计数器一次:
在这里插入图片描述
两个线程都完成后,ac的值必须是2,因为每次递增都是不可分割的操作。而vc的值则不一定是2。每次递增包括读取vc的值、将读取的值递增、并将结果写回vc。有可能两个线程对vc的递增操作交叉进行:
1)线程1读取vc的值,为0。
2)线程2读取vc的值,仍然是0。
3)线程1将0递增到1,然后将结果写回vc。
4)线程2将0递增到1,然后将结果写回vc。
这样,vc的最终值为1,尽管它被递增了两次。

  1. 假设一个任务会计算出另一个任务需要的重要的值。当第一个任务计算出该值后,它必须将其传达给第二个任务:
std::atomic<bool> valAvailable(false);
auto imptValue = computeImportantValue(); 	// 计算值
valAvailable = true; 				// 告诉其他任务值可用

1)但编译器看到的只是对独立一对变量的赋值。一般来说,编译器允许对这种不相关的赋值进行重排。对于这样的赋值序列(其中 a、b、x 和 y 对应于独立变量):a = b; x = y;编译器可能将它们重排如下:x = y; a = b;
2)即使编译器不重排它们,底层硬件也可能会这样做,因为这有时可以使代码运行得更快。
3)使用std::atomic会限制代码的重排方式,写入std::atomic变量之前的代码不能在之后执行。

  1. 将valAvailable声明为volatile不会强制相同的代码重排限制:
//volatile是用来告诉编译器,它们正在处理行为不正常的内存
volatile bool valAvailable(false);
auto imptValue = computeImportantValue();
valAvailable = true; // 其他线程可能在看到对 imptValue 的赋值之前看到这个赋值!
  1. 普通内存的特点是,如果将一个值写入内存位置,该值将一直保留在那里,直到有东西覆盖它:
//编译器可以通过消除对 y 的赋值来优化生成的代码
int x;		// 一个普通的int变量
auto y = x; 	// 读取 x
y = x; 		// 再次读取 x

如果将一个值写入普通内存,且之后没有再读取该位置的值,然后再次写入该内存位置时,第一次写入的操作可以被省略:

//编译器可以省略第一个赋值语句
x = 10; // 写入 x
x = 20; // 再次写入 x

编译器可以将它们视为:

auto y = x; 	// 读取 x
x = 20; 	// 写入 x
//虽然我们一般不会写出这样的代码,但在编译器处理执行模板实例化、内联和各种常见的重排优化之后,结果中出现编译器可以消除的冗余加载和死存储的情况并不罕见

1)这种优化只有在内存正常运行时才有效。“特殊”内存则不然。
2)最常见的特殊内存是用于内存映射 I/O 的内存。此类内存中的位置实际上是与外围设备进行通信,而不是读取或写入普通内存。
3)auto y = x; y = x; 如果 x 对应于例如温度传感器报告的值,那么温度可能在第一次和第二次读取之间发生了变化,所以并不冗余。
4)x = 10; x = 20; 如果 x 对应于无线电发射器的控制端口,那么可能是代码正在向无线电发送命令,10 的值对应于与 20 不同的命令。

  1. 通过volatile可以告诉编译器正在处理特殊内存。它对编译器的意义是“不要对该内存上的操作执行任何优化”:
volatile int x;
auto y = x;  // 读取 x;注意:y是普通int类型
y = x;      // 再次读取 x(不能被优化掉)
x = 10;     // 写入 x(不能被优化掉)
x = 20;     // 再次写入 x

在处理特殊内存时,必须保留看似多余的加载和死存储,这也解释了为什么std::atomic不适合这种工作。编译器被允许消除std::atomic上的这种冗余操作。从概念上讲,编译器可能会采用这种方式:

std::atomic<int> x;
auto y = x; 	// 概念上读取 x(见下文)
y = x; 		// 概念上再次读取 x(见下文)
x = 10; 	// 写入 x
x = 20; 	// 再次写入 x

并将其优化为:

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

现在,碰巧这两个语句在 x 是std::atomic时都不会编译:

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

可以将 x 的值赋给 y,但这需要使用 std::atomic 的成员函数 load 和 store。load 成员函数以原子方式读取 std::atomic 的值,而 store 成员函数以原子方式写入值:

//这可以编译,但是读取x与初始化或存储到y是一个独立的函数调用,这表明没有理由期望两个语句作为一个整体作为单个原子操作来执行
std::atomic<int> y(x.load()); 	// 读取 x
y.store(x.load()); 			// 再次读取 x

对于上面的代码,编译器可以通过将 x 的值存储在寄存器中而不是读取两次来“优化”它:

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

由于 std::atomic 和 volatile 有不同的用途,它们甚至可以一起使用:

//如果 vai 对应于一个被多个线程并发访问的内存映射 I/O 位置,那么上述方法可能会很有用
volatile std::atomic<int> vai; // 对 vai 的操作是原子的,并且不能被优化

一些开发人员即使在不必要的情况下也喜欢使用 std::atomic 的 load 和 store 成员函数,因为这样可以在源代码中明确表明所涉及的变量不是“普通的”。

2 要点速记

  1. std::atomic 用于从多个线程访问数据而不使用互斥锁。它是编写并发软件的工具。
  2. volatile 用于不应该优化读取和写入的内存。它是用于处理特殊内存的工具。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐