Namespaces
Variants

memory_order

From cppreference.net
定义于头文件 <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
(C++26 起弃用)
具有此内存顺序的加载操作在受影响的内存位置上执行 消费操作 :当前线程中依赖于当前加载值的读写操作不能在此加载之前重排。在其他线程中释放同一原子变量的数据依赖变量的写入对当前线程可见。在大多数平台上,这仅影响编译器优化(参见下文 释放-消费顺序 )。
memory_order_acquire 具有此内存顺序的加载操作在受影响的内存位置上执行 获取操作 :当前线程中的读写操作不能在此加载之前重排。其他线程中释放同一原子变量的所有写入对当前线程可见(参见下文 释放-获取顺序 )。
memory_order_release 具有此内存顺序的存储操作执行 释放操作 :当前线程中的读写操作不能在此存储之后重排。当前线程中的所有写入对其他线程中获取同一原子变量的线程可见(参见下文 释放-获取顺序 ),且携带依赖关系到原子变量的写入对消费同一原子的其他线程可见(参见下文 释放-消费顺序 )。
memory_order_acq_rel 具有此内存顺序的读-修改-写操作既是 获取操作 又是 释放操作 。当前线程中的内存读写操作不能在加载之前重排,也不能在存储之后重排。其他线程中释放同一原子变量的所有写入在修改前可见,且修改对其他线程中获取同一原子变量的线程可见。
memory_order_seq_cst 具有此内存顺序的加载操作执行 获取操作 ,存储操作执行 释放操作 ,读-修改-写操作同时执行 获取操作 释放操作 ,且存在一个单一全序,其中所有线程都以相同顺序观察到所有修改(参见下文 顺序一致顺序 )。

宽松排序

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

例如,当 x y 初始值为零时,

// 线程 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 的结果,因为尽管在线程1中A 先序于 B,在线程2中C 先序于 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的角度看)发生的 happened-before 内存写入(非原子和宽松原子操作),都会在那些与加载操作形成 依赖关系 的线程B操作中成为 可见副作用 。也就是说,一旦原子加载完成,线程B中使用该加载值的操作符和函数都能确保看到线程A对内存的写入内容。

同步仅在 释放 获取 同一原子变量的线程之间建立。其他线程可能看到与同步线程中一方或双方不同的内存访问顺序。

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

这种排序的典型使用场景包括:对很少写入的并发数据结构(路由表、配置信息、安全策略、防火墙规则等)进行读取访问,以及通过指针传递实现发布的发布-订阅场景。也就是说,当生产者通过指针发布数据,消费者可以通过该指针访问信息时:无需让消费者看到生产者写入内存的所有其他内容(在弱排序架构上这可能是一项昂贵的操作)。此类场景的一个例子是 rcu_dereference

请注意,目前(2015年2月)尚无已知的生产编译器能够追踪依赖链:consume操作会被提升为acquire操作。

释放序列

如果某个原子操作以存储释放(store-release)执行,且其他多个线程对该原子执行读-修改-写操作,则会形成“释放序列”:所有对同一原子执行读-修改-写操作的线程会与首个线程及其他线程形成同步,即使这些操作不具备 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,会观察到以下情况之一:

  • 在单一全序中位于B之前、且修改了M的上一个操作A的结果,
  • 或者,若存在这样的A,B可能观察到某个对M的非 memory_order_seq_cst 修改且不 先发生于 A的结果,
  • 或者,若不存在这样的A,B可能观察到某个与M无关的非 memory_order_seq_cst 修改结果。

如果存在一个 memory_order_seq_cst atomic_thread_fence 操作 X 顺序早于 B,则 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 sequenced-before X,Y sequenced-before 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 s X 和 Y,使得 A 顺序先于 X,Y 顺序先于 B,且在单一全序中 X 出现在 Y 之前。

请注意这意味着:

1) 一旦非标记为 memory_order_seq_cst 的原子操作介入,顺序一致性就会丧失,
2) 顺序一致栅栏仅确立栅栏自身的全序关系,在一般情况下并不确立原子操作的全序关系( sequenced-before 不同于 happens-before ,它不是跨线程关系)。

在多个生产者-多个消费者的场景中,当所有消费者必须观察到所有生产者以相同顺序执行的操作时,可能需要采用顺序排序。

总顺序排序在所有多核系统上都需要完整的内存屏障CPU指令。这可能会成为性能瓶颈,因为它强制受影响的内存访问传播到每个核心。

与 volatile 的关系

在同一执行线程内,通过 volatile左值 进行的访问(读取和写入)不能被重排到跨越同一线程内序列点所分隔的可观察副作用(包括其他volatile访问)之前,但该顺序不能被其他线程保证观察到,因为volatile访问不建立线程间同步。

此外,volatile访问不具备原子性(并发读写属于 数据竞争 ),且不会对内存进行排序(非volatile内存访问可以围绕volatile访问自由重排序)。

一个显著的例外是Visual Studio,在默认设置下,每个volatile写入都具有释放语义,每个volatile读取都具有获取语义( Microsoft Docs ),因此volatile可用于线程间同步。标准 volatile 语义不适用于多线程编程,但当应用于 sig_atomic_t 变量时,它们足以满足在同一线程内运行的 signal 处理程序的通信需求。编译器选项 /volatile:iso 可用于恢复符合标准的行为,这在目标平台为ARM时是默认设置。

示例

参考文献

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

参见

C++ 文档 关于 memory order

外部链接

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