并发:C++ 11内存模型中的原子和易失性

Abh*_*t_K 55 c++ parallel-processing concurrency multithreading c++11

全局变量在2个不同核心上的2个并发运行线程之间共享.线程写入和读取变量.对于原子变量,一个线程可以读取过时值吗?每个核心可能在其缓存中具有共享变量的值,并且当一个线程在缓存中写入其副本时,不同核心上的另一个线程可能从其自己的缓存中读取过时值.或者编译器执行强大的内存排序以从其他缓存中读取最新值?c ++ 11标准库具有std :: atomic支持.这与volatile关键字有何不同?在上述场景中,volatile和atomic类型的行为方式有何不同?

Ant*_*ams 87

首先,volatile并不意味着原子访问.它专为内存映射I/O和信号处理等设计.volatile使用时完全没有必要std::atomic,除非您的平台文档另有说明,否则volatile与线程之间的原子访问或内存排序无关.

如果您有一个在线程之间共享的全局变量,例如:

std::atomic<int> ai;
Run Code Online (Sandbox Code Playgroud)

然后,可见性和排序约束取决于您用于操作的内存排序参数,以及锁,线程和对其他原子变量的访问的同步效果.

在没有任何额外同步的情况下,如果一个线程将值写入,ai那么没有什么能保证另一个线程在任何给定时间段内都能看到该值.该标准规定它应"在合理的时间段内"可见,但任何给定的访问都可能返回陈旧的值.

默认的内存排序为所有变量的std::memory_order_seq_cst所有std::memory_order_seq_cst操作提供单个全局总排序.这并不意味着您不能获得过时的价值,但它确实意味着您获得的价值决定了并且取决于您的操作所处的总订单的位置.

如果你有2个共享变量,x并且y最初为零,并且有一个线程写入1 x而另一个写入2 y,则读取两者的第三个线程可以看到(0,0),(1,0),(0,2) )或(1,2)因为操作之间没有排序约束,因此操作可以以全局顺序的任何顺序出现.

如果两个写入都来自同一个线程,而x=1之前y=2的读取线程读取y之前x(0,2)不再是有效选项,因为读取y==2意味着先前的写入x是可见的.其他3对(0,0),(1,0)和(1,2)仍然是可能的,这取决于2次读取如何与2次写入交错.

如果您使用其他内存排序,std::memory_order_relaxed或者std::memory_order_acquire然后进一步放宽约束,则单个全局排序不再适用.如果没有额外的同步,线程甚至不必同意两个存储的排序来分离变量.

保证您具有"最新"值的唯一方法是使用读取 - 修改 - 写入操作,例如exchange(),compare_exchange_strong()fetch_add().读 - 修改 - 写操作有一个额外的约束,它们总是对"最新"值进行ai.fetch_add(1)操作,因此一系列线程的一系列操作将返回一系列没有重复或间隙的值.在没有额外约束的情况下,仍然无法保证哪些线程会看到哪些值.

使用原子操作是一个复杂的主题.我建议你阅读很多背景材料,并在用atomics编写生产代码之前检查已发布的代码.在大多数情况下,编写使用锁的代码更容易,而且效率明显降低.

  • VS不会为`volatile`访问发出内存屏障指令.他们所做的是推动处理器保证负载始终是负载获取,并且存储总是存储 - 释放到C/C++代码级别,并禁止某些优化以便实现这种情况.这与带有`std :: memory_order_seq_cst`的`std :: atomic`不同,它需要一个`MFENCE`或`LOCK`ed指令以及存储和/或加载. (5认同)
  • @BartoszMilewski:我不相信你问的循环优化是有效的.对于某些大N,编译器可能会将其更改为每N次迭代的读取,但可能不会完全从循环中删除读取,因为它会违反"应该在合理的时间段内可见"子句.它只是一个"应该",而不是"必须",但我看不出任何违反它的合理实施. (4认同)
  • 我想补充一点,这个问题没有明确定义。值是否过时取决于额外的同步。如果您有一个变量,并且一个线程不断向其写入一系列值(例如,1、2...),而另一个线程读取(例如 3),那么 3 是否是过时值?你需要一些其他确认,当你读 3 时,确实 4 已经被写入。但这需要一些其他观察,这些观察与你读 3 处于发生之前的关系。在你读 4 之前,一定有人已经向你传达了 4 的读或 3. SC 访问不会发生这种情况。 (2认同)
  • @BartoszMilewski:我对"陈旧"的解释是,某个线程写的后一个值比读取的值要多.除非有一些同步约束使得后面的read可观察,否则这不是问题. (2认同)

Jam*_*nze 30

volatile 并且原子操作具有不同的背景,并且以不同的意图引入.

volatile从返回的日期开始,主要用于在访问内存映射IO时阻止编译器优化.现代编译器通常只会抑制优化volatile,尽管在某些机器上,这对于内存映射IO来说是不够的.除了信号处理程序的特殊情况setjmp, longjmpgetjmp序列(C标准,以及信号,Posix标准,提供额外保证),在没有特殊附加说明的现代机器上必须被视为无用(围栏或内存屏障),硬件可能会重新排序甚至抑制某些访问.既然你不应该使用setjmp 等.在C++中,这或多或少会留下信号处理程序,而在多线程环境中,至少在Unix下,也有更好的解决方案.并且可能是内存映射的IO,如果您正在处理内核代码并且可以确保编译器生成所讨论的平台所需的任何内容.(根据标准,volatile访问是可观察的行为,编译器必须遵守.但编译器可以定义"访问"的含义,并且大多数似乎将其定义为"执行加载或存储机器指令".在现代处理器上,甚至不意味着总线上必然存在读取或写入周期,更不用说按照预期的顺序.)

鉴于这种情况,C++标准添加了原子访问,它确实提供了跨线程的一定数量的保证; 特别地,围绕原子访问生成的代码将包含必要的附加指令以防止硬件重新排序访问,并确保访问传播到多核机器上的核之间共享的全局存储器.(在标准化工作的某一点上,微软提议将这些语义添加到 volatile,并且我认为他们的一些C++编译器会这样做.然而,在讨论了委员会中的问题之后,包括微软代表在内的普遍共识是,它是最好离开volatile具有其原始意义,并定义原子类型.)或者只使用系统级原语,如互斥体,它们执行代码中需要的任何指令.(他们必须这样做.如果没有关于内存访问顺序的保证,你就无法实现互斥锁.)

  • 你的意思是**没有**,不是吗? (8认同)

小智 8

以下是两件事的基本概要:

1) Volatile 关键字:
告诉编译器这个值可能随时改变,因此它永远不应该将它缓存在寄存器中。在 C 中查找旧的“register”关键字。“Volatile”基本上是“-”运算符来“注册”的“+”。现代编译器现在会进行“注册”用于默认情况下显式请求的优化,因此您只能再看到“易失性”。使用 volatile 限定符将保证您的处理永远不会使用陈旧的值,仅此而已。

2) 原子性:
原子性操作在单个时钟滴答中修改数据,因此任何其他线程都不可能在更新过程中访问数据。它们通常仅限于硬件支持的任何单时钟汇编指令;诸如 ++、-- 和交换 2 个指针之类的东西。请注意,这并没有说明不同线程将运行原子指令的 ORDER,只是它们永远不会并行运行。这就是为什么您拥有所有这些附加选项来强制订购的原因。

  • 原子操作通常会抽象为单个硬件时钟周期,但据我所知,这既不是 100% 保证/必要的,也不是唯一的要求(例如,许多体系结构也需要某些对齐)。人们应该始终声明意图,而不是依赖于“它在 X 架构上以正确的方式工作”。 (2认同)

Kar*_*uru 5

易失性和原子性有不同的用途。

Volatile :通知编译器避免优化。该关键字用于意外更改的变量。因此,它可以用来表示硬件状态寄存器、ISR 变量、多线程应用程序中共享的变量。

Atomic:它也用于多线程应用程序。但是,这可以确保在多线程应用程序中使用时不会出现锁定/停顿。原子操作不受竞争且不可分割。在多线程应用程序中,很少有关键的使用场景是检查锁是否空闲或已使用、原子地添加值并返回添加的值等。