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

一个全局变量在两个不同核心上并发运行的两个线程之间共享。线程写入和读取变量。对于原子变量,一个线程可以读取一个过时的值吗?每个核心的缓存中可能有一个共享变量的值,当一个线程在缓存中写入它的副本时,另一个不同核心上的线程可能从自己的缓存中读取过期值。或者编译器执行强内存排序以从其他缓存读取最新值?C + + 11标准库支持 std: : 原子。这个关键字和易失性关键字有什么不同?在上面的场景中,易失性类型和原子类型的行为会有什么不同?

46605 次浏览

volatile和原子操作有不同的背景,并且 是出于不同的目的。

volatile可以追溯到很久以前,主要是为了防止 访问内存映射 IO 时的编译器优化 编译器往往只会抑制对 volatile的优化, 尽管在一些机器上,这甚至不足以映射内存 除了信号处理程序和 setjmp的特殊情况外, longjmpgetjmp序列(其中 C 标准,并在情况下 的信号,Posix 标准,给予额外的保证) ,它必须是 在现代机器上被认为是无用的 指令(栅栏或内存屏障) ,硬件可以重新排序或 甚至禁止某些访问。因为您不应该使用 setjmp 在 C + + 中,这或多或少会留下信号处理程序,并且在 多线程环境,至少在 Unix 下,有更好的 解决方案,以及可能的内存映射 IO,如果你是 处理内核代码,并且可以确保编译器生成 有关平台所需的一切资料。(根据 标准,volatile访问是可观察的行为,编译器 但是编译器可以定义 大多数人似乎把它定义为一个负载或者 商店的指令被执行了 处理器,甚至不意味着必须有一个读或写 在公共汽车上骑自行车,更不用说按照你期望的顺序了。)

在这种情况下,C + + 标准添加了原子访问,这样做 跨线程提供一定数量的保证; 特别是, 围绕原子访问生成的代码将包含必要的 附加的指令,以防止硬件重新排序 访问,并确保访问向下传播到全局 多核机器上核心之间共享的内存 在标准化的努力下,微软建议将这些语义添加到 我认为他们的一些 C + + 编译器可以 委员会讨论的问题,然而,一般 包括微软代表在内的共识是 最好让 volatile保留其原始含义,并定义 或者仅仅使用系统级的原语,比如 互斥对象,它们执行代码中需要的任何指令。 (他们必须这样做。没有一些保证,你就不能实现互斥锁 关于内存访问的顺序。)

首先,volatile并不意味着原子访问。它是为诸如内存映射 I/O 和信号处理之类的事情而设计的。与 std::atomic一起使用时,volatile是完全不必要的,除非您的平台文档另有说明,否则 volatile与线程之间的原子访问或内存排序没有任何关系。

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

std::atomic<int> ai;

那么可见性和排序约束取决于操作使用的内存排序参数,以及锁、线程和访问其他原子变量的同步效果。

在没有任何其他同步的情况下,如果一个线程向 ai写入一个值,那么就不能保证另一个线程在任何给定的时间段内看到该值。该标准规定,它应该“在合理的时间内”可见,但是任何给定的访问都可能返回一个过时的值。

std::memory_order_seq_cst的默认内存顺序为所有变量的所有 std::memory_order_seq_cst操作提供一个单一的全局总顺序。这并不意味着您不能得到过时的值,但是这确实意味着您确实得到的值是确定的,并且是由您的操作在这个总顺序中的位置决定的。

如果有2个共享变量 xy,最初为0,并且有一个线程写1到 x,另一个线程写2到 y,那么读取这两个变量的第三个线程可能会看到(0,0)、(1,0)、(0,2)或(1,2) ,因为操作之间没有排序约束,因此操作可能以全局顺序的任何顺序出现。

如果两个写入都来自同一个线程,这个线程在 y=2之前写入 x=1,而读取线程在 x之前读取 y,那么(0,2)就不再是一个有效的选项,因为读取 y==2意味着之前对 x的写入是可见的。其他3对(0,0)、(1,0)和(1,2)仍然是可能的,这取决于2个读操作与2个写操作的交织方式。

如果您使用其他内存排序,如 std::memory_order_relaxedstd::memory_order_acquire,那么约束将进一步放松,单个全局排序将不再适用。如果没有额外的同步,线程甚至不必就两个存储区的顺序达成一致,以分离变量。

保证具有“最新”值的唯一方法是使用读-修改-写操作,如 exchange()compare_exchange_strong()fetch_add()。读-修改-写操作有一个额外的约束,它们总是对“最新”值进行操作,因此一系列线程执行的 ai.fetch_add(1)操作序列将返回一系列没有重复或间隔的值。在没有其他约束的情况下,仍然不能保证哪些线程会看到哪些值。特别值得注意的是,使用 RMW 操作并不会迫使其他线程的更改更快地显示出来,这只是意味着,如果更改是由 RMW 看到的 没有,那么所有线程必须同意,它们在修改该原子变量的顺序上晚于 RMW 操作。来自不同线程的存储仍然可以延迟任意数量的时间,这取决于 CPU 事实上什么时候将存储发送到内存(而不仅仅是它自己的存储缓冲区) ,身体上执行线程的 CPU 之间的距离有多远(在多处理器系统的情况下) ,以及缓存一致性协议的细节。

使用原子操作是一个复杂的主题。我建议您阅读大量的背景资料,并在使用原子编写生产代码之前检查已发布的代码。在大多数情况下,编写使用锁的代码更容易,效率也不会明显降低。

下面是这两件事的基本概要:

1)易变关键字:
告诉编译器这个值可以随时改变,因此它不应该将它缓存在寄存器中。在 C 语言中查找旧的“ register”关键字“ Volative”基本上是“ register”的“ +”的“-”操作符。现代编译器现在做的优化,“寄存器”用于显式请求默认情况下,所以你只看到’易失性’了。使用易失性限定符将保证您的处理永远不会使用过期值,仅此而已。

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

挥发性和原子有不同的用途。

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

原子弹: 它也用于多线程应用程序的情况。但是,这可以确保在多线程应用程序中使用时不存在锁/停止。原子操作是不分种族和不可分割的。在多线程应用程序中,使用的关键场景很少是检查锁是空闲的还是使用的、自动添加值并返回添加值等等。