命名空间
变体
操作

内存顺序

来自 cppreference.com
< c‎ | atomic
在头文件 <stdatomic.h> 中定义
enum memory_order

{
    memory_order_relaxed,
    memory_order_consume,
    memory_order_acquire,
    memory_order_release,
    memory_order_acq_rel,
    memory_order_seq_cst

};
(自 C11)

memory_order 指定了如何对围绕原子操作的内存访问(包括常规的非原子内存访问)进行排序。在没有对多核系统的任何约束的情况下,当多个线程同时读取和写入多个变量时,一个线程可能观察到值的变化顺序与另一个线程写入它们的顺序不同。实际上,变化的表观顺序甚至可以在多个读取器线程之间不同。由于内存模型允许的编译器转换,即使在单处理器系统上也可能发生一些类似的效果。

语言和库中所有原子操作的默认行为提供顺序一致排序(参见下面的讨论)。该默认行为可能会影响性能,但库的原子操作可以被赋予额外的memory_order参数来指定编译器和处理器必须为该操作执行的除了原子性之外的确切约束。

内容

[编辑] 常量

在头文件 <stdatomic.h> 中定义
解释
memory_order_relaxed 松散操作:没有对其他读取或写入施加同步或排序约束,只有此操作的原子性得到保证(参见下面的松散排序)。
memory_order_consume 具有此内存顺序的加载操作对受影响的内存位置执行消费操作:当前线程中依赖于当前加载值的任何读取或写入都不能在该加载之前重新排序。释放相同原子变量的其他线程中对数据依赖变量的写入在当前线程中可见。在大多数平台上,这仅影响编译器优化(参见下面的释放-消费排序)。
memory_order_acquire 具有此内存顺序的加载操作对受影响的内存位置执行获取操作:当前线程中任何读取或写入都不能在该加载之前重新排序。释放相同原子变量的其他线程中的所有写入在当前线程中可见(参见下面的释放-获取排序)。
memory_order_release 具有此内存顺序的存储操作执行释放操作:当前线程中任何读取或写入都不能在该存储之后重新排序。当前线程中的所有写入在获取相同原子变量的其他线程中可见(参见下面的释放-获取排序),并且将依赖关系传递到原子变量的写入在消费相同原子的其他线程中可见(参见下面的释放-消费排序)。
memory_order_acq_rel 具有此内存顺序的读-修改-写操作既是获取操作,又是释放操作。当前线程中任何内存读取或写入都不能在加载之前重新排序,也不能在存储之后重新排序。释放相同原子变量的其他线程中的所有写入在修改之前可见,并且修改在获取相同原子变量的其他线程中可见。
memory_order_seq_cst 具有此内存顺序的加载操作执行获取操作,存储操作执行释放操作,并且读-修改-写操作既执行获取操作,又执行释放操作,再加上所有线程都以相同的顺序观察所有修改的单一总顺序存在(参见下面的顺序一致排序)。

[编辑] 松散排序

标记为 memory_order_relaxed 的原子操作不是同步操作;它们不会对并发内存访问施加顺序。它们只保证原子性和修改顺序一致性。

例如,对于初始为零的 xy

// 线程 1:
r1 = atomic_load_explicit(y, memory_order_relaxed); // A
atomic_store_explicit(x, r1, memory_order_relaxed); // B
// 线程 2:
r2 = atomic_load_explicit(x, memory_order_relaxed); // C
atomic_store_explicit(y, 42, memory_order_relaxed); // D
允许生成 r1 == r2 == 42,因为虽然 A 在线程 1 中按顺序先于 B 并且 C 在线程 2 中按顺序先于 D,但没有任何东西可以阻止 D 在 y 的修改顺序中先于 A,以及 B 在 x 的修改顺序中先于 C。D 对 y 的副作用可能在线程 1 的加载 A 中可见,而 B 对 x 的副作用可能在线程 2 的加载 C 中可见。特别是,如果 D 在线程 2 的 C 之前完成,无论是因为编译器重新排序还是在运行时,这都可能发生。

松散内存排序的典型用例是递增计数器,例如引用计数器,因为这只需要原子性,而不需要排序或同步。

[编辑] 释放-消费排序

如果线程 A 中的原子存储标记为 memory_order_release,线程 B 中来自相同变量的原子加载标记为 memory_order_consume,并且线程 B 中的加载读取了线程 A 中存储写入的值,那么线程 A 中的存储依赖于顺序先于线程 B 中的加载。

所有发生之前线程 A 中的原子存储的内存写入(非原子和松散原子)都成为线程 B 中那些操作的可见副作用,这些操作依赖于加载,也就是说,一旦原子加载完成,线程 B 中使用从加载获得的值的那些运算符和函数保证能看到线程 A 写入内存的内容。

同步仅在释放消费相同原子变量的线程之间建立。其他线程可能看到与同步线程之一或两者不同的内存访问顺序。

在除 DEC Alpha 之外的所有主流 CPU 上,依赖排序是自动的,不会为这种同步模式发出额外的 CPU 指令,只会影响某些编译器优化(例如,编译器被禁止对依赖链中涉及的对象执行推测性加载)。

这种排序的典型用例涉及对很少写入的并发数据结构(路由表、配置、安全策略、防火墙规则等)的读取访问,以及带有指针中介发布的发布者-订阅者情况,也就是说,当生产者通过消费者可以使用它访问信息的指针发布信息时:不需要使生产者写入内存的所有其他内容对消费者可见(这在弱排序架构上可能是昂贵的操作)。这种场景的一个例子是rcu_dereference

请注意,目前(2015 年 2 月)没有已知的生产编译器跟踪依赖链:消费操作被提升为获取操作。

[编辑] 释放序列

如果一些原子是存储-释放的,并且其他几个线程对该原子执行读-修改-写操作,就会形成“释放序列”:所有执行对同一原子的读-修改-写的线程都会与第一个线程以及彼此同步,即使它们没有memory_order_release语义。这使得在没有对各个消费者线程施加不必要的同步的情况下,单生产者 - 多消费者情况成为可能。

[编辑] 发布-获取排序

如果线程 A 中的原子存储被标记为 memory_order_release,线程 B 中从同一变量的原子加载被标记为 memory_order_acquire,并且线程 B 中的加载读取了线程 A 中存储的值,那么线程 A 中的存储将 *同步* 线程 B 中的加载。

所有在从线程 A 的角度来看 *发生在* 原子存储 *之前* 的内存写入(包括非原子和松散原子),都将成为线程 B 中的 *可见副作用*。也就是说,一旦原子加载完成,线程 B 保证能看到线程 A 写入内存的所有内容。这个承诺仅在 B 实际返回 A 存储的值,或者从发布序列中后面的值时才有效。

同步仅在 *发布* 和 *获取* 相同原子变量的线程之间建立。其他线程可能看到与同步线程中的一个或两个线程不同的内存访问顺序。

在强排序系统(例如 x86、SPARC TSO、IBM 大型机等)上,发布-获取排序对于大多数操作来说是自动的。这种同步模式不会发出额外的 CPU 指令;只会影响某些编译器优化(例如,编译器被禁止将非原子存储移动到原子存储-发布的后面,或者在原子加载-获取之前执行非原子加载)。在弱排序系统(ARM、Itanium、PowerPC)上,会使用特殊的 CPU 加载或内存栅栏指令。

互斥锁,例如 互斥锁原子自旋锁,是发布-获取同步的一个例子:当线程 A 释放锁,线程 B 获取锁时,在线程 A 的上下文中发生在临界区(在发布之前)的所有内容都必须对在线程 B(在获取之后)执行相同临界区的上下文可见。

[编辑] 顺序一致排序

标记为 memory_order_seq_cst 的原子操作不仅以与发布/获取排序相同的方式排序内存(所有 *发生在* 线程中存储 *之前* 的内容都将成为读取该存储的线程中的 *可见副作用*),而且还为所有标记为该排序的原子操作建立 *单一全局修改顺序*。

形式上,

每个从原子变量 M 加载的 memory_order_seq_cst 操作 B 都会观察到以下情况之一

  • 修改 M 的最后一个操作 A 的结果,该操作出现在单一全局顺序中 B 之前,
  • 或者,如果存在这样的 A,B 可能观察到对 M 的某个修改的结果,该修改不是 memory_order_seq_cst,并且不会 *发生在* A *之前*,
  • 或者,如果没有这样的 A,B 可能观察到对 M 的某个无关修改的结果,该修改不是 memory_order_seq_cst

如果存在一个 *在* B *之前排序* 的 memory_order_seq_cst atomic_thread_fence 操作 X,那么 B 观察到以下情况之一

  • 出现在单一全局顺序中 X 之前的 M 的最后一个 memory_order_seq_cst 修改,
  • 出现在 M 的修改顺序中后面的 M 的某个无关修改。

对于对 M 的一对原子操作,称为 A 和 B,其中 A 写入,B 读取 M 的值,如果存在两个 memory_order_seq_cst atomic_thread_fence X 和 Y,并且如果 A *在* X *之前排序*,Y *在* B *之前排序*,并且 X 出现在单一全局顺序中 Y 之前,那么 B 观察到以下情况之一

  • A 的影响,
  • 出现在 M 的修改顺序中 A 后面的 M 的某个无关修改。

对于对 M 的一对原子修改,称为 A 和 B,如果 B 出现在 M 的修改顺序中 A 后面,那么

  • 存在一个 memory_order_seq_cst atomic_thread_fence X,使得 A *在* X *之前排序*,并且 X 出现在单一全局顺序中 B 之前,
  • 或者,存在一个 memory_order_seq_cst atomic_thread_fence Y,使得 Y *在* B *之前排序*,并且 A 出现在单一全局顺序中 Y 之前,
  • 或者,存在 memory_order_seq_cst atomic_thread_fence X 和 Y,使得 A *在* X *之前排序*,Y *在* B *之前排序*,并且 X 出现在单一全局顺序中 Y 之前。

注意,这意味着

1) 一旦未标记为 memory_order_seq_cst 的原子操作出现,顺序一致性就会丢失,
2) 顺序一致栅栏仅为栅栏本身建立全局顺序,在一般情况下不适用于原子操作(*在...之前排序* 不是跨线程关系,与 *发生在...之前* 不同)。

顺序排序可能对于多个生产者-多个消费者的情况是必需的,在这些情况下,所有消费者都必须以相同的顺序观察所有生产者的操作。

全局顺序排序需要在所有多核系统上使用完整的内存栅栏 CPU 指令。这可能会成为性能瓶颈,因为它会迫使受影响的内存访问传播到每个核心。

[编辑] 与 volatile 的关系

在执行线程内,通过 volatile 左值 的访问(读和写)不能在同一线程内由序列点隔开的可观察副作用(包括其他 volatile 访问)之外重新排序,但是这种顺序不能保证被另一个线程观察到,因为 volatile 访问不建立线程间同步。

此外,volatile 访问不是原子性的(并发读写是一个 数据竞争),并且不会排序内存(非 volatile 内存访问可以自由地在 volatile 访问周围重新排序)。

一个值得注意的例外是 Visual Studio,在默认设置下,每个 volatile 写入都具有发布语义,每个 volatile 读取都具有获取语义 (Microsoft 文档),因此 volatile 可以用于线程间同步。标准的 volatile 语义不适用于多线程编程,尽管它们足以用于例如与同一线程中运行的 信号 处理程序的通信,当应用于 sig_atomic_t 变量时。

[编辑] 示例

[编辑] 参考资料

  • C23 标准 (ISO/IEC 9899:2024)
  • 7.17.1/4 memory_order (p: TBD)
  • 7.17.3 顺序和一致性 (p: TBD)
  • C17 标准 (ISO/IEC 9899:2018)
  • 7.17.1/4 memory_order (p: 200)
  • 7.17.3 顺序和一致性 (p: 201-203)
  • C11 标准 (ISO/IEC 9899:2011)
  • 7.17.1/4 memory_order (p: 273)
  • 7.17.3 顺序和一致性 (p: 275-277)

[编辑] 参见

C++ 文档 用于 内存顺序

[编辑] 外部链接

1.  MOESI 协议
2.  x86-TSO:用于 x86 多处理器的严谨且可用的程序员模型 P. Sewell 等人,2010
3.  ARM 和 POWER 松散内存模型的入门教程 P. Sewell 等人,2012
4.  MESIF:用于点对点互连的双跳缓存一致性协议 J.R. Goodman, H.H.J. Hum,2009
5.  内存模型 Russ Cox,2021