From c8632bbf488a3d1ae4fdd644ffd24989373e2caf Mon Sep 17 00:00:00 2001 From: Coned Zot Date: Wed, 16 Sep 2026 01:41:55 -0700 Subject: [PATCH 1/7] first draft on memory order --- ...37\345\255\220\346\223\215\344\275\234.md" | 306 +++++++++++++++++- 1 file changed, 303 insertions(+), 3 deletions(-) diff --git "a/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" "b/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" index 919b4db..95159ff 100644 --- "a/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" +++ "b/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" @@ -803,14 +803,314 @@ RISC-V 采用的也是**弱序内存模型**(weakly-ordered memory model), 各位一定要区分,这种强弱其实也只是一种分类而已,不同的指令集架构大多都还是有所不同的,并不会完全一样。例如: `x86` 的 TSO(Total Store Order)是强一致性模型的一种,但并不是所有强一致性模型都是 TSO。 -### 宽松定序 +### 一点历史 + +本节术语译名参照 [cppreference 的内存次序文档](https://zh.cppreference.com/cpp/atomic/memory_order),首次引入时附上英文名称。 + +C++11 在引入线程和原子操作的同时,也引入了标准内存模型。这项设计的一大特点,就是通过可移植的语言接口,让开发者能够细致地选择内存次序。我们可以指定一个操作需要怎样的同步,而不用针对每种处理器分别编写指令序列。这种控制能力很适合用来实现底层并发设施,当然,它也并非 C++ 独有。 + +随着编译器和硬件实现经验的积累,这套模型也经历了一些修订。本章采用 **C++20 及以后的释放序列(Release sequence)定义**,并说明 C++26 对 `consume` 的调整。一些旧资料使用的规则与现在不同,讲到释放序列时,我们会简单介绍修改的原因,各位无需先学一遍旧模型。相关基础规范可以查阅标准委员会的 [C++20 工作草案](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/n4861.pdf)。 + +### 三组内存次序 + +`std::memory_order` 用来指定原子操作提供的内存次序。下面主要根据它们在线程之间提供的同步保证,比较三组内存次序: + +| 类别 | 常量 | 关注的保证 | +| --- | --- | --- | +| 序列一致定序(Sequentially consistent ordering) | `std::memory_order_seq_cst` | 所有序列一致定序操作共同遵循一个全序(Total order) | +| 宽松定序(Relaxed ordering) | `std::memory_order_relaxed` | 保证原子访问,但不为不同对象的访问建立同步关系 | +| 释放-获得定序(Release-acquire ordering) | `std::memory_order_release`、`std::memory_order_acquire`、`std::memory_order_acq_rel` | 通过同步关系,将一个线程之前的操作结果发布给另一个线程 | + +我们先讲 `seq_cst`,它是这里使用的加载(Load)、存储(Store)和读修改写(Read-modify-write,简称 RMW)操作的默认内存序。例如,下面两次调用指定的内存序相同: + +```cpp +x.store(1); +x.store(1, std::memory_order_seq_cst); +``` + +从 C++20 开始,也可以使用 `std::memory_order::seq_cst` 这种写法。下文统一使用前一种写法。 + +> 此外还有 `std::memory_order_consume`。**目前不要使用它,请改用 `std::memory_order_acquire`。** 我们会在最后简单说明它的现状。 + +### 从线程内的顺序到先发生于 + +在看具体测试之前,我们先认识三个描述顺序的说法。各位暂时不必记住严格的定义,先理解它们分别连接了哪些操作。 + +先看一个线程里的两条语句: + +```cpp +data.store(42, std::memory_order_relaxed); +ready.store(1, std::memory_order_release); +``` + +我们先写数据,再设置标志。这种线程内部按照程序规定的先后关系,可以直观地称为 **程序顺序(Program order,简称 po)**。在这里,两条语句之间对应的是 C++ 中的 **先序于(Sequenced-before)** 关系。我们使用程序顺序来帮助理解这些简单例子,并不是说所有 C++ 表达式都按源码从左到右求值。 + +你可能会问:既然有这个顺序,另一个线程看到标志时,不就应该能看到数据了吗? + +还差一步。**一个线程内部有先后关系,并不直接等于另一个线程能按这个顺序观察到结果。** 我们还需要在线程之间建立联系。 + +假设另一个线程以获得语义(Acquire)读取 `ready`,而且读到了上面具有释放语义(Release)的存储写入的 `1`。这时,释放存储就与这次获得加载建立了 **同步于(Synchronizes-with,简称 sw)** 关系。可以把它理解成两个线程之间的一次交接:生产者在发布之前完成的写入,通过这次交接对消费者后续的读取可见。 + +注意,关键是 **获得加载读到了相应释放存储写入的值**。仅仅一个线程用了释放操作,另一个线程用了获得操作,还不足以建立这个联系。 + +把线程内的顺序和线程间的同步接起来,就得到了下面这条路径: + +```text +生产者:写入 data -> 按程序顺序释放存储 ready + | + | 同步于:获得加载读到这次存储的值 + v +消费者: 获得加载 ready -> 按程序顺序读取 data +``` + +沿着这条路径,生产者对 `data` 的写入就 **先发生于(Happens-before,简称 hb)** 消费者对 `data` 的读取。我们可以直观地把先发生于关系理解为:由线程内的先后关系,以及线程间的同步关系,串起来形成的先后保证。它可以继续传递,例如 A 先发生于 B,B 先发生于 C,就能推出 A 先发生于 C。 + +这里的“先发生”说的是内存模型提供的顺序保证,不是看时钟判断哪个线程先运行。仅仅观察到一个线程似乎早一点执行完,并不能代替上述关系。它也不意味着所有线程中的所有操作都能比较先后;没有被这些关系连接起来的操作,仍然可能没有这样的先后保证。 + +后面看到消息传递测试时,我们就能用这条路径解释为什么“读到新标志”会保证“读到新数据”;再看存储缓冲测试和对独立写入的独立读取测试,则可以留意这样的联系在哪里没有建立。 + +### 用试金石测试提出问题 + +**试金石测试(Litmus test)**是一段很小的并发程序,用来回答某个特定结果是否可能出现。它省去了大部分业务代码,让我们能够集中讨论一个内存次序问题。 + +下面使用三种常见的模式: + +- **存储缓冲(Store Buffering,简称 SB)**:两个线程各自写入一个变量,再读取对方写入的变量。两次读取能否都得到旧值? +- **消息传递(Message Passing,简称 MP)**:生产者先写入数据,再设置标志。消费者能否看到新标志,却读到旧数据? +- **对独立写入的独立读取(Independent Reads of Independent Writes,简称 IRIW)**:两个写线程分别更新不同的变量,两个读线程以相反的顺序读取它们。两个读线程能否都只看到自己先读取的那个变量的更新? -### 释放-获取定序 +下面的表格是 C++ 线程函数体的简写。每一列代表一个线程,其中的语句按照源码顺序从上到下排列。**同一行不表示同时执行。** 所有共享原子对象都在线程启动前完成初始化,每个测试只讨论一次执行,没有重置操作或额外的访问。`r1` 等变量保存各读线程自己的结果,测试程序可以在 `join()` 之后统一收集这些结果。 -### 释放-消费定序 +我们问的是 **C++ 是否允许某个结果出现**,而不是某台机器上出现这个结果的频率。“允许”并不意味着每次运行、每个编译器或每种处理器都一定会产生这个结果。Arm 的 [试金石测试入门文档](https://learn.arm.com/learning-paths/servers-and-cloud-computing/memory_consistency/litmus_syntax/)也区分了分析内存模型与在硬件上运行实验这两件事。 ### 序列一致定序 +`std::memory_order_seq_cst` 提供**序列一致定序**,我们从它开始理解。当测试中的所有访问都使用序列一致定序时,可以想象把它们排进同一个序列,并保留每个线程内部的语句顺序。每次加载都读取这个序列中对相应对象最近一次存储写入的值;如果前面没有存储,就读取初始值。 + +这个序列用来解释程序能观察到的结果,并不要求处理器真的把整个程序一条指令接一条指令地串行执行。具体可参阅标准草案中的 [序列一致定序规则](https://eel.is/c++draft/atomics.order)。 + +#### SB:双方都没有看到对方的写入? + +假设两个线程各自发出一个通知,然后检查对方是否也发出了通知。这就是 **存储缓冲** 模式。它的名字来自一种能产生其典型结果的硬件机制,不过,我们可以直接从 C++ 的角度提出这个问题。 + +初始状态如下: + +```cpp +std::atomic x{ 0 }, y{ 0 }; +``` + +| 线程 A | 线程 B | +| --- | --- | +| `x.store(1);` | `y.store(1);` | +| `r1 = y.load();` | `r2 = x.load();` | + +**能否出现 `r1 == 0 && r2 == 0`?使用序列一致定序时,不能。** + +我们尝试推导一下。A 要读到 `y` 的初始值 `0`,它的读取就必须排在 B 的写入之前。而 A 自己的写入又必须排在这次读取之前。同理,B 要读到 `x == 0`,它的读取也必须排在 A 的写入之前: + +```text +A 写入 x -> A 读取 y -> B 写入 y -> B 读取 x -> A 写入 x +``` + +绕了一圈,又回到了起点。显然,不存在一个能满足这些要求的序列。 + +其它结果就很好理解了。如果 A 先执行完自己的两个操作,结果就是 `(r1, r2) == (0, 1)`;如果 B 先执行完,则是 `(1, 0)`;如果两次写入都排在两次读取之前,则是 `(1, 1)`。 + +所以,序列一致定序在这里允许三种结果,但排除了 `(0, 0)`。接下来减弱内存次序时,各位可以留意这个被排除的结果。 + +### 宽松定序 + +`std::memory_order_relaxed` 保留了访问的原子性,但不会为对其它对象的访问提供同步。我们把 SB 中的所有访问都改成宽松定序: + +| 线程 A | 线程 B | +| --- | --- | +| `x.store(1, std::memory_order_relaxed);` | `y.store(1, std::memory_order_relaxed);` | +| `r1 = y.load(std::memory_order_relaxed);` | `r2 = x.load(std::memory_order_relaxed);` | + +现在,**`r1 == 0 && r2 == 0` 是允许出现的**。两次读取都可能观察到对方所写对象的初始值。我们不再要求这四次访问能够排进前面那样的序列一致定序序列。我们可以理解为,在同一个线程内,读写的顺序是任意的。也就是说,当线程 A,B 都先执行了读取操作,然后他们再执行写入操作时,两个 r 都读取的是 0。 + +这并不是说宽松加载可以读出任意数字。在这个测试中,每个对象的初始值都是 `0`,之后也只会被写入 `1`,所以读取结果仍然只能是 `0` 或 `1`。对同一个原子对象的访问仍然需要遵守一致性规则;这里放宽的是跨越 `x` 和 `y` 两个对象的额外定序要求。 + +#### MP:看到了标志,就看到了数据吗? + +下面换一种通信方式。生产者先准备消息,再通知消费者消息已经就绪。消费者先检查通知,然后读取消息。这就是 **消息传递**。 + +我们用原子整数 `data` 表示消息,用 `ready` 表示消息是否就绪,方便在不同内存序下比较同一个测试。初始状态如下: + +```cpp +std::atomic data{ 0 }, ready{ 0 }; +``` + +| 生产者 | 消费者 | +| --- | --- | +| `data.store(42, std::memory_order_relaxed);` | `r1 = ready.load(std::memory_order_relaxed);` | +| `ready.store(1, std::memory_order_relaxed);` | `r2 = data.load(std::memory_order_relaxed);` | + +**能否出现 `r1 == 1 && r2 == 0`?使用宽松定序时,可以。** 读到 `ready == 1`,并不会在生产者之前对 `data` 的写入与消费者之后对 `data` 的读取之间建立同步关系。 + +这里的消费者不会等待 `ready` 变为 `1`,而是依次读取 `ready` 和 `data`,然后我们讨论读取 `ready` 得到 `1` 的情况。这样就能集中讨论内存次序,而不必引入等待的细节。 + +如果把所有访问都改为序列一致定序,就能排除这个结果。读到 `ready == 1`,意味着在共同的序列中,消费者对 `ready` 的读取排在生产者对 `ready` 的写入之后。生产者对 `data` 的写入又在对 `ready` 的写入之前,所以消费者接下来读取 `data` 时,会得到 `42`。 + +不过,只是传递一条消息,也需要所有操作共同遵循一个序列一致定序序列吗?下一节我们来看一种更有针对性的方式。 + +#### 宽松定序的用途 + +如果只需要维护一个数值,例如统计事件次数,就可以使用宽松定序: + +```cpp +std::atomic count{ 0 }; + +// 每个工作线程记录一次事件: +count.fetch_add(1, std::memory_order_relaxed); +``` + +每次递增都是原子的读修改写操作,所以并发递增不会互相覆盖。如果两个工作线程各自递增 100 次,并且没有其它修改,那么在 `join()` 两个线程之后,用宽松加载读取计数就会得到 `200`。这里的计数只用来记录事件次数,并不用来通知另一份数据已经准备好。 + +### 释放-获得定序 + +释放-获得定序允许一个线程通过原子对象发布之前的操作结果。关键在于:**获得操作读取了释放操作写入的值**,或者读取了后面将介绍的释放序列中的值。这样建立的关系就叫做 **同步于**。 + +我们只修改 MP 中对 `ready` 的访问: + +| 生产者 | 消费者 | +| --- | --- | +| `data.store(42, std::memory_order_relaxed);` | `r1 = ready.load(std::memory_order_acquire);` | +| `ready.store(1, std::memory_order_release);` | `r2 = data.load(std::memory_order_relaxed);` | + +**现在,`r1 == 1 && r2 == 0` 就不允许出现了。** 如果对 `ready` 的获得加载读到了 `1`,它读取的就是生产者对 `ready` 的释放存储写入的值。通过这个同步关系,生产者之前对 `data` 的写入对消费者之后对 `data` 的读取可见: + +```text +向 data 存储 42 + -> 向 ready 释放存储 1 + -> 从 ready 获得加载,读到 1 + -> 从 data 加载,读到 42 +``` + +注意,对 `data` 的访问仍然使用宽松定序。这里通过 `ready` 提供同步,就足以排除我们关心的结果。当然,也可以让所有存储使用释放语义、所有加载使用获得语义,同样能排除这个结果。 + +如果消费者读到的是 `ready == 0` 呢?那就说明这次读取没有读到生产者的通知,上述同步关系没有建立。此时读取 `data` 可能得到 `0`,也可能得到 `42`。 + +#### 再看 SB + +让 SB 中的存储使用释放语义,加载使用获得语义: + +| 线程 A | 线程 B | +| --- | --- | +| `x.store(1, std::memory_order_release);` | `y.store(1, std::memory_order_release);` | +| `r1 = y.load(std::memory_order_acquire);` | `r2 = x.load(std::memory_order_acquire);` | + +**两次读取仍然可以都得到 `0`。** 在这个结果中,两次获得加载都没有读到对方释放存储写入的值,因此没有建立 MP 中那样的同步关系。 + +到这里,三种选择的差别就体现出来了:宽松定序允许 MP 中“新标志、旧数据”的结果;释放-获得定序排除了这个结果;序列一致定序则进一步排除了 SB 中“两次都读到零”的结果。 + +#### IRIW:两个读者能看到不同的顺序吗? + +SB 中两次读取都读到了初始值,这或许还不难理解。那如果读线程确实获得了写线程的更新呢?**IRIW** 用两个独立的写线程和两个读线程来讨论这个问题。 + +初始时,`x` 和 `y` 都是值为 `0` 的原子整数: + +| 写线程 A | 写线程 B | 读线程 C | 读线程 D | +| --- | --- | --- | --- | +| `x.store(1, std::memory_order_release);` | `y.store(1, std::memory_order_release);` | `r1 = x.load(std::memory_order_acquire);` | `r3 = y.load(std::memory_order_acquire);` | +| | | `r2 = y.load(std::memory_order_acquire);` | `r4 = x.load(std::memory_order_acquire);` | + +我们关心的是,下面这组结果能否在同一次执行中出现: + +```text +读线程 C:r1 == 1, r2 == 0 +读线程 D:r3 == 1, r4 == 0 +``` + +C 看到了 `x` 的更新,却没有看到 `y` 的更新;D 看到了 `y` 的更新,却没有看到 `x` 的更新。**C++ 允许释放-获得定序出现这个结果**,所有访问都使用宽松定序时也允许。 + +每个读线程都从一个写线程获得了更新。不过,两个写线程是独立的:A 没有获得 B 的操作结果,B 也没有获得 A 的操作结果。因此,C 获得了 A 的更新,并不意味着它必须看到 B 的更新,对 D 也是同样的道理。 + +对比一下 MP:那里对数据和标志的写入来自同一个生产者,而且有先后顺序。这里的两个写线程则都没有发布对方的操作结果。这就是两个测试结论不同的原因。 + +**如果把每次访问都改为序列一致定序,IRIW 的这个结果就不允许出现。** C 观察到的结果要求: + +```text +写入 x -> C 读取 x -> C 读取 y -> 写入 y +``` + +D 观察到的结果却要求两次写入具有相反的顺序: + +```text +写入 y -> D 读取 y -> D 读取 x -> 写入 x +``` + +这两组要求无法同时放进一个序列。相比各自建立的释放-获得定序同步关系,全局的序列一致次序提供了这一额外保证。 + +#### 三种定序放在一起看 + +下面汇总刚才讨论的结果。其中,释放-获得定序一列表示所有存储使用释放语义、所有加载使用获得语义;MP 也可以像前面的例子一样,对数据保留宽松定序访问。 + +| 测试 | 讨论的结果 | 宽松定序 | 释放-获得定序 | 序列一致定序 | +| --- | --- | --- | --- | --- | +| MP | 新标志、旧数据:`(r1, r2) == (1, 0)` | 允许 | 不允许 | 不允许 | +| SB | 两次都读到 `0`:`(r1, r2) == (0, 0)` | 允许 | 允许 | 不允许 | +| IRIW | 各自只看到先读取的变量的更新:`(r1, r2, r3, r4) == (1, 0, 1, 0)` | 允许 | 允许 | 不允许 | + +这些是 C++ 提供的保证。特别是 IRIW 那一行,并不意味着每种处理器使用获得/释放指令时都会出现这个结果。**语言允许的结果,与具体平台上实际观察到的结果,需要区分开来。** + +#### `memory_order_acq_rel` + +读修改写操作同时承担读取和写入两个角色:读取旧值,再写入新值。`std::memory_order_acq_rel` 为其中的读取提供获得语义,为写入提供释放语义。例如: + +```cpp +auto previous = state.exchange(next, std::memory_order_acq_rel); +``` + +它的读取可以获得之前发布的操作结果,它的写入则可以向其它线程发布此前的操作结果。单独的加载可以使用获得语义,单独的存储可以使用释放语义;组合后的 `acq_rel` 用于既读取又写入的操作。各内存序的说明可参阅 [GCC 原子操作文档](https://gcc.gnu.org/onlinedocs/gcc/_005f_005fatomic-Builtins.html)。 + +### 释放序列 + +发布操作还可以通过另一种方式与读线程建立联系。假设生产者通过一个状态变量发布数据,在消费者读取之前,另一个线程使用原子读修改写操作修改了这个状态。消费者还能获得生产者最初发布的数据吗? + +**释放序列** 就规定了什么情况下可以。它并不是一个额外的 `memory_order` 参数。 + +这里需要引入一个术语:**修改顺序(Modification order)**,指的是对单个原子对象的所有修改所形成的顺序。从 C++20 开始,释放序列以一个释放操作为起点,随后沿着同一对象的修改顺序,由连续的读修改写操作延续。这些读修改写操作可以来自任意线程,也可以使用宽松定序。加载不修改对象,因此不会打断这个序列。具体可参阅标准草案的[释放序列定义](https://eel.is/c++draft/intro.races#5)。 + +我们稍微修改一下 MP,数据仍然使用原子对象。初始状态如下: + +```cpp +std::atomic data{ 0 }, state{ 0 }; +``` + +| 生产者 | 中间线程 | 消费者 | +| --- | --- | --- | +| `data.store(42, std::memory_order_relaxed);` | `state.fetch_add(1, std::memory_order_relaxed);` | `r1 = state.load(std::memory_order_acquire);` | +| `state.store(1, std::memory_order_release);` | | `r2 = data.load(std::memory_order_relaxed);` | + +**如果 `r1 == 2`,能否出现 `r2 == 0`?不能。** 这里一共只有一次递增操作。要产生 `2`,它就必须读到生产者写入的 `1`,再将其更新为 `2`。这个读修改写操作延续了生产者的释放序列。因此,消费者通过获得加载读到 `2` 时,就与最初的释放操作建立了同步关系,接下来会读到 `data == 42`。 + +这个测试中的中间线程无需等待生产者。如果它先执行,就会把初始值 `0` 增加到 `1`,之后生产者再存入 `1`,整个过程中不会有操作产生 `2`。我们的问题只针对消费者读到了 `2` 的情况。 + +中间线程的宽松读修改写操作让生产者的发布效果沿释放序列传递,但它本身并没有为中间线程获得这些结果,也没有发布中间线程自己的其它操作结果。 + +#### 为什么强调 C++20? + +按照现行规则,一次单独的原子存储会结束之前的释放序列,即使它由最初的生产者执行也是如此。来看下面这个变体,初始状态仍然是 `data == 0`、`state == 0`: + +| 生产者 | 消费者 | +| --- | --- | +| `data.store(42, std::memory_order_relaxed);` | `r1 = state.load(std::memory_order_acquire);` | +| `state.store(1, std::memory_order_release);` | `r2 = data.load(std::memory_order_relaxed);` | +| `state.store(2, std::memory_order_relaxed);` | | + +在 C++20 及以后,**`r1 == 2 && r2 == 0` 是允许出现的**。最后存入 `2` 的操作没有延续之前的释放序列。如果把最后这次存储改为释放存储,就能直接向消费者发布数据,从而排除这个结果。 + +在 C++20 之前,执行最初释放操作的线程所进行的后续存储,也可以延续释放序列。这条很少使用的特殊规则容易引起误解,对代码改动也很敏感,还增加了推理编译器变换的复杂度,并给实现施加了额外约束,因此被移除了。保留下来的读修改写规则仍然有用,引用计数就是其应用之一。标准委员会的 [P0982R1](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0982r1.html) 说明了这次修改的原因。 + +### 释放-消费定序(Release-consume ordering) + +**目前不要使用 `std::memory_order_consume`,请改用 `std::memory_order_acquire`。** + +消费定序(Consume)最初希望为依赖于加载值的后续访问提供更弱的定序,例如通过刚读取的指针访问已经发布的对象。不过,在编译器优化过程中保留这些依赖关系很困难。GCC 的[原子内建函数文档](https://gcc.gnu.org/onlinedocs/gcc/_005f_005fatomic-Builtins.html)明确说明,其消费定序使用获得语义来实现。 + +在 C++26 工作草案中,消费定序已被弃用,并具有获得语义,见[兼容性规范](https://eel.is/c++draft/depr.atomics.order)。入门阶段知道这个名字即可。 + ### 与 `volatile` 的关系 C++11 在引入线程和原子操作的同时,也引入了标准内存模型。这项设计的一大特点,就是通过可移植的语言接口,让开发者能够细致地选择内存次序。我们可以指定一个操作需要怎样的同步,而不用针对每种处理器分别编写指令序列。这种控制能力很适合用来实现底层并发设施,当然,它也并非 C++ 独有。 -随着编译器和硬件实现经验的积累,这套模型也经历了一些修订。本章采用 **C++20 及以后的释放序列(Release sequence)定义**,并说明 C++26 对 `consume` 的调整。一些旧资料使用的规则与现在不同,讲到释放序列时,我们会简单介绍修改的原因,各位无需先学一遍旧模型。相关基础规范可以查阅标准委员会的 [C++20 工作草案](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/n4861.pdf)。 +随着编译器和硬件实现经验的积累,这套模型也经历了一些修订。本章采用 **C++20 及以后的释放序列(Release sequence)定义**,以及 **C++26 及以后的先序于(Sequenced-before)与先发生于(Happens-before)定义**,并说明 C++26 对 `consume` 的调整。一些旧资料使用的规则与现在不同,讲到具体内容时,我们会简单介绍采用这些标准的原因。相关基础规范可以查阅标准委员会的 [C++20 工作草案](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/n4861.pdf)。 ### 三组内存次序 @@ -821,7 +820,7 @@ C++11 在引入线程和原子操作的同时,也引入了标准内存模型 | 宽松定序(Relaxed ordering) | `std::memory_order_relaxed` | 保证原子访问,但不为不同对象的访问建立同步关系 | | 释放-获得定序(Release-acquire ordering) | `std::memory_order_release`、`std::memory_order_acquire`、`std::memory_order_acq_rel` | 通过同步关系,将一个线程之前的操作结果发布给另一个线程 | -我们先讲 `seq_cst`,它是这里使用的加载(Load)、存储(Store)和读修改写(Read-modify-write,简称 RMW)操作的默认内存序。例如,下面两次调用指定的内存序相同: +我们先讲 `seq_cst`,它是这里使用的加载、存储和读-改-写(RMW)操作的默认内存序。例如,下面两次调用指定的内存序相同: ```cpp x.store(1); @@ -843,13 +842,13 @@ data.store(42, std::memory_order_relaxed); ready.store(1, std::memory_order_release); ``` -我们先写数据,再设置标志。这种线程内部按照程序规定的先后关系,可以直观地称为 **程序顺序(Program order,简称 po)**。在这里,两条语句之间对应的是 C++ 中的 **先序于(Sequenced-before)** 关系。我们使用程序顺序来帮助理解这些简单例子,并不是说所有 C++ 表达式都按源码从左到右求值。 +我们先写数据,再设置标志。这种线程内部按照程序规定的先后关系,可以直观地称为 **先序于(Sequenced-before)** 关系。我们使用它来帮助理解这些简单例子,并不是说所有 C++ 表达式都按源码顺序执行,在 C++26 之前,在一些表达式中,求值顺序可能没有指定,为了简化,本文采用 C++26 对先序于的定义 [^4]。 你可能会问:既然有这个顺序,另一个线程看到标志时,不就应该能看到数据了吗? 还差一步。**一个线程内部有先后关系,并不直接等于另一个线程能按这个顺序观察到结果。** 我们还需要在线程之间建立联系。 -假设另一个线程以获得语义(Acquire)读取 `ready`,而且读到了上面具有释放语义(Release)的存储写入的 `1`。这时,释放存储就与这次获得加载建立了 **同步于(Synchronizes-with,简称 sw)** 关系。可以把它理解成两个线程之间的一次交接:生产者在发布之前完成的写入,通过这次交接对消费者后续的读取可见。 +假设另一个线程以获得语义(Acquire)读取 `ready`,而且读到了上面具有释放语义(Release)的存储写入的 `1`。这时,释放存储就与这次获得加载建立了上文提到的 **同步于** 关系。可以把它理解成两个线程之间的一次交接:生产者在发布之前完成的写入,通过这次交接对消费者后续的读取可见。 注意,关键是 **获得加载读到了相应释放存储写入的值**。仅仅一个线程用了释放操作,另一个线程用了获得操作,还不足以建立这个联系。 @@ -863,15 +862,17 @@ ready.store(1, std::memory_order_release); 消费者: 获得加载 ready -> 按程序顺序读取 data ``` -沿着这条路径,生产者对 `data` 的写入就 **先发生于(Happens-before,简称 hb)** 消费者对 `data` 的读取。我们可以直观地把先发生于关系理解为:由线程内的先后关系,以及线程间的同步关系,串起来形成的先后保证。它可以继续传递,例如 A 先发生于 B,B 先发生于 C,就能推出 A 先发生于 C。 +沿着这条路径,生产者对 `data` 的写入就 **先发生于(Happens-before,简称 hb)** 消费者对 `data` 的读取。我们可以直观地把先发生于关系理解为:由线程内的先后关系(先序于),以及线程间的同步关系(同步于),串起来形成的先后保证。它可以继续传递,例如 A 先发生于 B,B 先发生于 C,就能推出 A 先发生于 C。 + +这里的“先发生”说的是内存模型提供的顺序保证,不是看现实中的时钟判断哪个线程先运行。仅仅观察到一个线程似乎早一点执行完,并不能代替上述关系。它也不意味着所有线程中的所有操作都能比较先后;没有被这些关系连接起来的操作,仍然可能没有这样的先后保证。 -这里的“先发生”说的是内存模型提供的顺序保证,不是看时钟判断哪个线程先运行。仅仅观察到一个线程似乎早一点执行完,并不能代替上述关系。它也不意味着所有线程中的所有操作都能比较先后;没有被这些关系连接起来的操作,仍然可能没有这样的先后保证。 +后面看到消息传递测试时,我们就能用这条路径解释为什么“读到新标志”会保证“读到新数据”;下文中的存储缓冲测试(SB)和对独立写入的独立读取测试(IRIW)都能体现这样的联系在哪里没有建立。 -后面看到消息传递测试时,我们就能用这条路径解释为什么“读到新标志”会保证“读到新数据”;再看存储缓冲测试和对独立写入的独立读取测试,则可以留意这样的联系在哪里没有建立。 +[^4]: 事实上,先序于在 C++11,C++17,C++20,C++26,做过多轮修改,程序具体按什么顺序执行直到 C++26 才最终被完整定义。 ### 用试金石测试提出问题 -**试金石测试(Litmus test)**是一段很小的并发程序,用来回答某个特定结果是否可能出现。它省去了大部分业务代码,让我们能够集中讨论一个内存次序问题。 +**试金石测试(Litmus test)**是一段很小的并发程序,用来回答某个特定结果是否可能出现。它省去了大部分代码,让我们能够集中讨论一个内存次序问题。 下面使用三种常见的模式: @@ -879,9 +880,7 @@ ready.store(1, std::memory_order_release); - **消息传递(Message Passing,简称 MP)**:生产者先写入数据,再设置标志。消费者能否看到新标志,却读到旧数据? - **对独立写入的独立读取(Independent Reads of Independent Writes,简称 IRIW)**:两个写线程分别更新不同的变量,两个读线程以相反的顺序读取它们。两个读线程能否都只看到自己先读取的那个变量的更新? -下面的表格是 C++ 线程函数体的简写。每一列代表一个线程,其中的语句按照源码顺序从上到下排列。**同一行不表示同时执行。** 所有共享原子对象都在线程启动前完成初始化,每个测试只讨论一次执行,没有重置操作或额外的访问。`r1` 等变量保存各读线程自己的结果,测试程序可以在 `join()` 之后统一收集这些结果。 - -我们问的是 **C++ 是否允许某个结果出现**,而不是某台机器上出现这个结果的频率。“允许”并不意味着每次运行、每个编译器或每种处理器都一定会产生这个结果。Arm 的 [试金石测试入门文档](https://learn.arm.com/learning-paths/servers-and-cloud-computing/memory_consistency/litmus_syntax/)也区分了分析内存模型与在硬件上运行实验这两件事。 +我们将在介绍过程中逐渐介绍这三个模式,并观察不同内存次序运行的结果。我们问的是 **C++ 是否允许某个结果出现**,而不是某台机器上出现这个结果的频率。“允许”并不意味着每次运行、每个编译器或每种处理器都一定会产生这个结果。Arm 的 [试金石测试入门文档](https://learn.arm.com/learning-paths/servers-and-cloud-computing/memory_consistency/litmus_syntax/)也区分了分析内存模型与在硬件上运行实验这两件事。 ### 序列一致定序 @@ -893,6 +892,8 @@ ready.store(1, std::memory_order_release); 假设两个线程各自发出一个通知,然后检查对方是否也发出了通知。这就是 **存储缓冲** 模式。它的名字来自一种能产生其典型结果的硬件机制,不过,我们可以直接从 C++ 的角度提出这个问题。 +下面的表格是 C++ 线程函数体的简写。每一列代表一个线程,其中的语句按照源码顺序从上到下排列。**同一行不表示同时执行。** 所有共享原子对象都在线程启动前完成初始化,每个测试只讨论一次执行,没有重置操作或额外的访问。`r1` 等变量保存各读线程自己的结果,测试程序可以在 `join()` 之后统一收集这些结果。 + 初始状态如下: ```cpp @@ -912,12 +913,24 @@ std::atomic x{ 0 }, y{ 0 }; A 写入 x -> A 读取 y -> B 写入 y -> B 读取 x -> A 写入 x ``` -绕了一圈,又回到了起点。显然,不存在一个能满足这些要求的序列。 +绕了一圈,又回到了起点。显然,不存在一个能满足这些要求的序列,因为序列不能是环形的。 其它结果就很好理解了。如果 A 先执行完自己的两个操作,结果就是 `(r1, r2) == (0, 1)`;如果 B 先执行完,则是 `(1, 0)`;如果两次写入都排在两次读取之前,则是 `(1, 1)`。 所以,序列一致定序在这里允许三种结果,但排除了 `(0, 0)`。接下来减弱内存次序时,各位可以留意这个被排除的结果。 +#### 从重排的角度理解 + +我们也可以从**重排(Reordering)**的角度理解刚才 SB 的结果。先把代码从上到下看作程序规定的顺序:向前移动,就是移到更上方;向后移动,就是移到更下方。接下来可以问:某个读写能否越过另一个操作,移动到它的前面或后面? + +在 SB 中,线程 A 先执行 `x.store(1)`,再执行 `y.load()`;线程 B 先执行 `y.store(1)`,再执行 `x.load()`。如果允许把两个线程的加载都移到各自的存储之前,就可以想象两次加载先读到初始值 `0`,之后才进行两次存储,从而得到 `(0, 0)`。 + +但这里的所有操作都使用序列一致定序。**在解释结果的共同序列中,必须保留每个线程先存储、后加载的顺序。** 因此,不能通过把加载移到存储之前来解释结果;保留原有顺序,又会得到前面推导出的环,所以 `(0, 0)` 被排除了。 + +不同线程的操作仍然可以交错。A 的两个操作在前、B 的两个操作在前,或者两次存储都在两次加载之前,都能保留各线程内部的顺序,分别得到前面列出的 `(0, 1)`、`(1, 0)` 和 `(1, 1)`。 + +这种“不能越过”的说法是理解内存次序的直观方式。标准约束的是程序可观察到的结果,并不要求编译器和处理器完全按照源码顺序执行每条指令;只要保持这些保证,实现仍然可以进行优化。相关描述可参阅 [cppreference 的内存次序文档](https://zh.cppreference.com/cpp/atomic/memory_order)。 + ### 宽松定序 `std::memory_order_relaxed` 保留了访问的原子性,但不会为对其它对象的访问提供同步。我们把 SB 中的所有访问都改成宽松定序: @@ -927,10 +940,14 @@ A 写入 x -> A 读取 y -> B 写入 y -> B 读取 x -> A 写入 x | `x.store(1, std::memory_order_relaxed);` | `y.store(1, std::memory_order_relaxed);` | | `r1 = y.load(std::memory_order_relaxed);` | `r2 = x.load(std::memory_order_relaxed);` | -现在,**`r1 == 0 && r2 == 0` 是允许出现的**。两次读取都可能观察到对方所写对象的初始值。我们不再要求这四次访问能够排进前面那样的序列一致定序序列。我们可以理解为,在同一个线程内,读写的顺序是任意的。也就是说,当线程 A,B 都先执行了读取操作,然后他们再执行写入操作时,两个 r 都读取的是 0。 +现在,**`r1 == 0 && r2 == 0` 是允许出现的**。两次读取都可能观察到对方所写对象的初始值。我们不再要求这四次访问能够排进前面那样的序列一致定序序列。从重排的角度,可以把这个结果想象成:线程 A 和 B 对另一个变量的加载都移到了各自的存储之前,两次加载因而都读到初始值 `0`。这是帮助理解结果的一种方式,并不要求硬件一定进行了这样的指令交换。 这并不是说宽松加载可以读出任意数字。在这个测试中,每个对象的初始值都是 `0`,之后也只会被写入 `1`,所以读取结果仍然只能是 `0` 或 `1`。对同一个原子对象的访问仍然需要遵守一致性规则;这里放宽的是跨越 `x` 和 `y` 两个对象的额外定序要求。 +宽松定序的重排规则可以直观地理解为:**它本身不为不同对象的读写增加额外的定序约束。** 因而,不能仅凭源码里“先写 `x`,再读 `y`”,就要求其它线程观察到的结果也符合这样的全局顺序。 + +不过,这不等于线程内部的读写顺序可以任意改变。同一原子对象的一致性规则、操作之间的数据依赖,以及其它同步关系仍然需要保留。我们只是放宽了内存次序带来的约束,并没有取消其它规则。 + #### MP:看到了标志,就看到了数据吗? 下面换一种通信方式。生产者先准备消息,再通知消费者消息已经就绪。消费者先检查通知,然后读取消息。这就是 **消息传递**。 @@ -948,7 +965,14 @@ std::atomic data{ 0 }, ready{ 0 }; **能否出现 `r1 == 1 && r2 == 0`?使用宽松定序时,可以。** 读到 `ready == 1`,并不会在生产者之前对 `data` 的写入与消费者之后对 `data` 的读取之间建立同步关系。 -这里的消费者不会等待 `ready` 变为 `1`,而是依次读取 `ready` 和 `data`,然后我们讨论读取 `ready` 得到 `1` 的情况。这样就能集中讨论内存次序,而不必引入等待的细节。 +这里的消费者不会等待 `ready` 变为 `1`,而是按源码顺序各读取一次 `ready` 和 `data`。宽松定序允许的观察结果,不必符合把所有线程按这个顺序交错执行的模型。 + +从重排的角度,可以用两种方式理解这个结果: + +- **生产者一侧**:把对 `ready` 的存储想象成移到了对 `data` 的存储之前。消费者在两次存储之间读取,就可能先读到 `ready == 1`,随后读到仍为 `0` 的 `data`。 +- **消费者一侧**:把对 `data` 的加载想象成移到了对 `ready` 的加载之前。消费者先读到 `data == 0`,接着生产者完成两次存储,消费者再读到 `ready == 1`,最后得到的仍然是 `r1 == 1 && r2 == 0`。 + +这两种情况不需要同时发生,任何一种都可以帮助我们理解“新标志、旧数据”的结果。宽松定序本身不阻止这里跨越不同对象的重排;但这仍然是解释允许结果的直观方式,并不是说每个平台都会生成这样的指令顺序。 如果把所有访问都改为序列一致定序,就能排除这个结果。读到 `ready == 1`,意味着在共同的序列中,消费者对 `ready` 的读取排在生产者对 `ready` 的写入之后。生产者对 `data` 的写入又在对 `ready` 的写入之前,所以消费者接下来读取 `data` 时,会得到 `42`。 @@ -967,6 +991,12 @@ count.fetch_add(1, std::memory_order_relaxed); 每次递增都是原子的读修改写操作,所以并发递增不会互相覆盖。如果两个工作线程各自递增 100 次,并且没有其它修改,那么在 `join()` 两个线程之后,用宽松加载读取计数就会得到 `200`。这里的计数只用来记录事件次数,并不用来通知另一份数据已经准备好。 +从重排的角度看,我们只关心对 `count` 的递增不丢失,并不要求这次递增与线程中对其它对象的独立读写保持额外的顺序。因此,宽松定序允许编译器和处理器在其它规则允许的范围内,将这些访问与计数操作重排,而无需为统计次数增加额外的定序约束。 + +例如,线程可能先处理一份数据,再给 `count` 加一。若 `count` 只是统计值,就无需通过它保证另一线程看到数据处理的结果。但如果我们想用“看到 `count` 增加了”来判断那份数据已经可见,它就承担了前面 `ready` 的角色,需要另行建立同步关系。 + +这里放宽的是与其它对象之间的顺序,不是把 `fetch_add` 拆成可以互相穿插的加载和存储。每次递增仍然是完整的原子操作;最后的检查又在两个线程都被 `join()` 之后,所以仍能得到最终计数 `200`。 + ### 释放-获得定序 释放-获得定序允许一个线程通过原子对象发布之前的操作结果。关键在于:**获得操作读取了释放操作写入的值**,或者读取了后面将介绍的释放序列中的值。这样建立的关系就叫做 **同步于**。 @@ -987,6 +1017,33 @@ count.fetch_add(1, std::memory_order_relaxed); -> 从 data 加载,读到 42 ``` +沿用前面从重排理解内存次序的方式,释放-获得定序的重排规则具有方向性: + +- **释放操作:前面的读写不能越过它,重排到它的后面。** 可以理解为,先完成要发布的工作,再发出通知。 +- **获得操作:后面的读写不能越过它,重排到它的前面。** 可以理解为,先接收通知,再使用通过这次同步获得的数据。 + +看生产者这一侧: + +```cpp +data.store(42, std::memory_order_relaxed); // 先准备 data +ready.store(1, std::memory_order_release); // 再通过 ready 发布 +``` + +释放存储约束的是它之前的读写。因此,可以直观地理解为:对 `data` 的写入不能跨过对 `ready` 的释放存储,被移到后面。否则,消费者就可能先看到 `ready == 1`,却还看不到已经准备好的 `data`。这正是我们希望排除的结果。 + +再看消费者这一侧: + +```cpp +r1 = ready.load(std::memory_order_acquire); // 先接收 ready +r2 = data.load(std::memory_order_relaxed); // 再读取 data +``` + +获得加载约束的是它之后的读写。因此,对 `data` 的读取不能跨过对 `ready` 的获得加载,被移到前面。否则,就相当于先读取了旧的 `data`,再看到 `ready == 1`,这同样无法达到消息传递的目的。 + +这里说的“读写”不只包括 `ready` 本身,也包括相应方向上对其它对象的访问,所以上面的约束能作用于 `data`。不过,**这两种约束都有方向,不能理解成操作两边的所有读写都不能重排**。单看释放语义,它不禁止后面的读写向前越过释放操作;单看获得语义,它不禁止前面的读写向后越过获得操作。具体能否移动,还需要满足其它语言规则和程序依赖。 + +最后要把两侧接起来:**消费者对 `ready` 的获得加载,必须读到生产者的释放存储写入的值,才建立这里的同步关系。** 前面的方向约束帮助我们理解各自线程中的顺序,而这次读取将两个线程连接起来,进而保证消费者读到 `data == 42`。 + 注意,对 `data` 的访问仍然使用宽松定序。这里通过 `ready` 提供同步,就足以排除我们关心的结果。当然,也可以让所有存储使用释放语义、所有加载使用获得语义,同样能排除这个结果。 如果消费者读到的是 `ready == 0` 呢?那就说明这次读取没有读到生产者的通知,上述同步关系没有建立。此时读取 `data` 可能得到 `0`,也可能得到 `42`。 @@ -1002,18 +1059,34 @@ count.fetch_add(1, std::memory_order_relaxed); **两次读取仍然可以都得到 `0`。** 在这个结果中,两次获得加载都没有读到对方释放存储写入的值,因此没有建立 MP 中那样的同步关系。 +从重排的角度看,关键在于这里是**先释放存储,后获得加载**。以线程 A 为例,`x.store(1, std::memory_order_release)` 后面紧接着 `y.load(std::memory_order_acquire)`: + +- 释放存储约束的是它**前面**的读写不能向后越过它,而对 `y` 的加载原本就在它后面。 +- 获得加载约束的是它**后面**的读写不能向前越过它,而对 `x` 的存储原本就在它前面。 + +因此,单看这两种语义,它们都没有禁止后面的 `y.load()` 向前越过 `x.store()`。线程 B 也是一样:后面的 `x.load()` 可以用同样的方式理解。于是,我们仍然可以把 `(0, 0)` 想象成两次加载先读到初始值,再发生两次存储。 + +对比 MP 就能看出方向的区别:生产者需要把对 `data` 的写入留在释放存储**之前**,消费者需要把对 `data` 的读取留在获得加载**之后**,正好都受到约束。而 SB 需要保留的是释放存储与其后获得加载之间的顺序,这两种单向约束本身并没有提供这个保证。 + +这仍然是在解释 C++ 允许的结果,不要求处理器真的交换两条指令。**释放操作后面接一个获得操作,并不等于它们之间形成了禁止双向重排的屏障。** + 到这里,三种选择的差别就体现出来了:宽松定序允许 MP 中“新标志、旧数据”的结果;释放-获得定序排除了这个结果;序列一致定序则进一步排除了 SB 中“两次都读到零”的结果。 #### IRIW:两个读者能看到不同的顺序吗? SB 中两次读取都读到了初始值,这或许还不难理解。那如果读线程确实获得了写线程的更新呢?**IRIW** 用两个独立的写线程和两个读线程来讨论这个问题。 -初始时,`x` 和 `y` 都是值为 `0` 的原子整数: +初始时,`x` 和 `y` 都是值为 `0` 的原子整数。为简化表格,下面用 `rel` 表示释放定序,`acq` 表示获得定序: + +```cpp +constexpr auto rel = std::memory_order_release; +constexpr auto acq = std::memory_order_acquire; +``` | 写线程 A | 写线程 B | 读线程 C | 读线程 D | | --- | --- | --- | --- | -| `x.store(1, std::memory_order_release);` | `y.store(1, std::memory_order_release);` | `r1 = x.load(std::memory_order_acquire);` | `r3 = y.load(std::memory_order_acquire);` | -| | | `r2 = y.load(std::memory_order_acquire);` | `r4 = x.load(std::memory_order_acquire);` | +| `x.store(1, rel);` | `y.store(1, rel);` | `r1 = x.load(acq);` | `r3 = y.load(acq);` | +| | | `r2 = y.load(acq);` | `r4 = x.load(acq);` | 我们关心的是,下面这组结果能否在同一次执行中出现: @@ -1054,6 +1127,16 @@ D 观察到的结果却要求两次写入具有相反的顺序: 这些是 C++ 提供的保证。特别是 IRIW 那一行,并不意味着每种处理器使用获得/释放指令时都会出现这个结果。**语言允许的结果,与具体平台上实际观察到的结果,需要区分开来。** +从重排的角度,也可以这样记住三者的区别: + +- **宽松定序**:不额外约束不同原子对象之间的访问顺序。 +- **释放-获得定序**:只约束特定方向。释放操作约束它之前的访问,获得操作约束它之后的访问;当获得加载读到相应的释放存储时,这些方向约束通过同步关系连接起来。 +- **序列一致定序**:所有相关原子操作都必须能放进同一个保留各线程内部顺序的全序中。 + +这里的“移到前面”或“移到后面”是帮助理解允许结果的方式,并不表示编译器或处理器一定真的交换了指令。C++ 规定的是程序可以观察到哪些结果。 + +<-- 这里,我想加一些有意思的历史。 --> + #### `memory_order_acq_rel` 读修改写操作同时承担读取和写入两个角色:读取旧值,再写入新值。`std::memory_order_acq_rel` 为其中的读取提供获得语义,为写入提供释放语义。例如: @@ -1062,7 +1145,9 @@ D 观察到的结果却要求两次写入具有相反的顺序: auto previous = state.exchange(next, std::memory_order_acq_rel); ``` -它的读取可以获得之前发布的操作结果,它的写入则可以向其它线程发布此前的操作结果。单独的加载可以使用获得语义,单独的存储可以使用释放语义;组合后的 `acq_rel` 用于既读取又写入的操作。各内存序的说明可参阅 [GCC 原子操作文档](https://gcc.gnu.org/onlinedocs/gcc/_005f_005fatomic-Builtins.html)。 +它的读取可以获得之前发布的操作结果,它的写入则可以向其它线程发布此前的操作结果。单独的加载可以使用获得语义,单独的存储可以使用释放语义;组合后的 `acq_rel` 用于既读取又写入的操作。 + +从重排角度看,`acq_rel` 把刚才介绍的两个方向合在同一个读修改写操作上:它之前的读写不能向后越过这个操作,它之后的读写也不能向前越过这个操作。可以把它想成一道双向的顺序边界:先前的工作可以通过它发布出去;同时,这个操作读取到的先前发布结果,也能供它之后的工作使用。 ### 释放序列 @@ -1070,7 +1155,11 @@ auto previous = state.exchange(next, std::memory_order_acq_rel); **释放序列** 就规定了什么情况下可以。它并不是一个额外的 `memory_order` 参数。 -这里需要引入一个术语:**修改顺序(Modification order)**,指的是对单个原子对象的所有修改所形成的顺序。从 C++20 开始,释放序列以一个释放操作为起点,随后沿着同一对象的修改顺序,由连续的读修改写操作延续。这些读修改写操作可以来自任意线程,也可以使用宽松定序。加载不修改对象,因此不会打断这个序列。具体可参阅标准草案的[释放序列定义](https://eel.is/c++draft/intro.races#5)。 +这里需要引入一个术语:**修改顺序(Modification order)**,指的是对单个原子对象的所有修改所形成的顺序。从 C++20 开始,释放序列以一个释放操作为起点,随后沿着同一对象的修改顺序,由连续的读修改写操作延续。这些读修改写操作可以来自任意线程,也可以使用宽松定序。加载不修改对象,因此不会打断这个序列。 + +释放序列本身描述的是同一原子对象上的修改如何连接起来;它不会给序列中每个宽松读修改写操作都添加重排约束。对释放操作周围的访问,仍由释放语义约束:释放操作之前的读写不能向后越过它。对最终获得加载之后的访问,则由获得语义约束:后续读写不能向前越过它。由于获得加载读到的是释放序列中的值,这两端的约束仍可通过与**最初**释放操作建立的同步关系连接起来。 + +这里的“连续”指的是该原子对象的修改顺序上没有其它修改插入,不是说这些 RMW 在时间上紧挨着执行。普通加载不属于修改,因此不会截断序列;另一个任意存储则会结束前一段释放序列。这个规则描述的是哪些操作仍属于同一发布链,不表示每个宽松 RMW 都自动获得此前发布的数据。 我们稍微修改一下 MP,数据仍然使用原子对象。初始状态如下: @@ -1089,6 +1178,8 @@ std::atomic data{ 0 }, state{ 0 }; 中间线程的宽松读修改写操作让生产者的发布效果沿释放序列传递,但它本身并没有为中间线程获得这些结果,也没有发布中间线程自己的其它操作结果。 +从周围操作的重排来看,生产者对 `data` 的写入在 `state` 的释放存储之前,因此受到生产者释放语义的约束。消费者的获得加载读到中间 RMW 写入的 `2` 后,仍然能沿释放序列与生产者的释放存储建立同步关系;消费者随后对 `data` 的读取受到获得语义的约束。中间线程的 RMW 使用宽松定序,所以它**不会**把中间线程在 RMW 之前的其它读写发布给消费者,也不会阻止这些周围操作仅凭该 RMW 与它重排。它的作用只是接续 `state` 上的释放序列。 + #### 为什么强调 C++20? 按照现行规则,一次单独的原子存储会结束之前的释放序列,即使它由最初的生产者执行也是如此。来看下面这个变体,初始状态仍然是 `data == 0`、`state == 0`: @@ -1101,15 +1192,15 @@ std::atomic data{ 0 }, state{ 0 }; 在 C++20 及以后,**`r1 == 2 && r2 == 0` 是允许出现的**。最后存入 `2` 的操作没有延续之前的释放序列。如果把最后这次存储改为释放存储,就能直接向消费者发布数据,从而排除这个结果。 -在 C++20 之前,执行最初释放操作的线程所进行的后续存储,也可以延续释放序列。这条很少使用的特殊规则容易引起误解,对代码改动也很敏感,还增加了推理编译器变换的复杂度,并给实现施加了额外约束,因此被移除了。保留下来的读修改写规则仍然有用,引用计数就是其应用之一。标准委员会的 [P0982R1](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0982r1.html) 说明了这次修改的原因。 +在 C++20 之前,执行最初释放操作的线程所进行的后续存储,也可以延续释放序列。这条很少使用的特殊规则容易引起误解,对代码改动也很敏感,还极大增加了推理编译器变换的复杂度,并给实现施加了额外约束,因此被移除了。保留下来的读修改写规则仍然有用,引用计数就是其应用之一。标准委员会的 [P0982R1](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0982r1.html) 说明了这次修改的原因。 + +一个不难发现的原因是,当一个线程执行释放写入,另一个线程执行释放读-改-写,这将在同一个变量上创造两个释放序列,极大地增加了复杂度。 ### 释放-消费定序(Release-consume ordering) **目前不要使用 `std::memory_order_consume`,请改用 `std::memory_order_acquire`。** -消费定序(Consume)最初希望为依赖于加载值的后续访问提供更弱的定序,例如通过刚读取的指针访问已经发布的对象。不过,在编译器优化过程中保留这些依赖关系很困难。GCC 的[原子内建函数文档](https://gcc.gnu.org/onlinedocs/gcc/_005f_005fatomic-Builtins.html)明确说明,其消费定序使用获得语义来实现。 - -在 C++26 工作草案中,消费定序已被弃用,并具有获得语义,见[兼容性规范](https://eel.is/c++draft/depr.atomics.order)。入门阶段知道这个名字即可。 +消费定序(Consume)最初希望为依赖于加载值的后续访问提供更弱的定序,例如通过刚读取的指针访问已经发布的对象。不过,在编译器优化过程中保留这些依赖关系很困难。GCC 的[原子内建函数文档](https://gcc.gnu.org/onlinedocs/gcc/_005f_005fatomic-Builtins.html)明确说明,其消费定序使用获得语义来实现。事实上,在任何一个主流的 C++ 编译器,都从未实现过消费定序。这些编译器都将消费定序直接替换为释放定序。基于此,在 C++26 工作草案中,消费定序已被弃用,并具有获得语义,见[兼容性规范](https://eel.is/c++draft/depr.atomics.order)。另外,得利于消费定序的删除,有关同步于,先发生于的定义都被大幅简化,本文因此采用 C++26 的定义。 ### 与 `volatile` 的关系 From 6073f292631321ecaa016c8f2d77d51efe3c1025 Mon Sep 17 00:00:00 2001 From: Coned Zot Date: Wed, 16 Sep 2026 03:38:47 -0700 Subject: [PATCH 3/7] Delete some useless reference --- ...16\345\216\237\345\255\220\346\223\215\344\275\234.md" | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git "a/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" "b/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" index b170101..7f68b2f 100644 --- "a/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" +++ "b/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" @@ -808,7 +808,7 @@ RISC-V 采用的也是**弱序内存模型**(weakly-ordered memory model), C++11 在引入线程和原子操作的同时,也引入了标准内存模型。这项设计的一大特点,就是通过可移植的语言接口,让开发者能够细致地选择内存次序。我们可以指定一个操作需要怎样的同步,而不用针对每种处理器分别编写指令序列。这种控制能力很适合用来实现底层并发设施,当然,它也并非 C++ 独有。 -随着编译器和硬件实现经验的积累,这套模型也经历了一些修订。本章采用 **C++20 及以后的释放序列(Release sequence)定义**,以及 **C++26 及以后的先序于(Sequenced-before)与先发生于(Happens-before)定义**,并说明 C++26 对 `consume` 的调整。一些旧资料使用的规则与现在不同,讲到具体内容时,我们会简单介绍采用这些标准的原因。相关基础规范可以查阅标准委员会的 [C++20 工作草案](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/n4861.pdf)。 +随着编译器和硬件实现经验的积累,这套模型也经历了一些修订。本章采用 **C++20 及以后的释放序列(Release sequence)定义**,以及 **C++26 及以后的先序于(Sequenced-before)与先发生于(Happens-before)定义**,并说明 C++26 对 `consume` 的调整。一些旧资料使用的规则与现在不同,讲到具体内容时,我们会简单介绍采用这些标准的原因。 ### 三组内存次序 @@ -880,13 +880,13 @@ ready.store(1, std::memory_order_release); - **消息传递(Message Passing,简称 MP)**:生产者先写入数据,再设置标志。消费者能否看到新标志,却读到旧数据? - **对独立写入的独立读取(Independent Reads of Independent Writes,简称 IRIW)**:两个写线程分别更新不同的变量,两个读线程以相反的顺序读取它们。两个读线程能否都只看到自己先读取的那个变量的更新? -我们将在介绍过程中逐渐介绍这三个模式,并观察不同内存次序运行的结果。我们问的是 **C++ 是否允许某个结果出现**,而不是某台机器上出现这个结果的频率。“允许”并不意味着每次运行、每个编译器或每种处理器都一定会产生这个结果。Arm 的 [试金石测试入门文档](https://learn.arm.com/learning-paths/servers-and-cloud-computing/memory_consistency/litmus_syntax/)也区分了分析内存模型与在硬件上运行实验这两件事。 +我们将在介绍过程中逐渐介绍这三个模式,并观察不同内存次序运行的结果。我们问的是 **C++ 是否允许某个结果出现**,而不是某台机器上出现这个结果的频率。“允许”并不意味着每次运行、每个编译器或每种处理器都一定会产生这个结果。Arm 的[试金石测试入门文档](https://learn.arm.com/learning-paths/servers-and-cloud-computing/memory_consistency/litmus_syntax/)也区分了分析内存模型与在硬件上运行实验这两件事。 ### 序列一致定序 `std::memory_order_seq_cst` 提供**序列一致定序**,我们从它开始理解。当测试中的所有访问都使用序列一致定序时,可以想象把它们排进同一个序列,并保留每个线程内部的语句顺序。每次加载都读取这个序列中对相应对象最近一次存储写入的值;如果前面没有存储,就读取初始值。 -这个序列用来解释程序能观察到的结果,并不要求处理器真的把整个程序一条指令接一条指令地串行执行。具体可参阅标准草案中的 [序列一致定序规则](https://eel.is/c++draft/atomics.order)。 +这个序列用来解释程序能观察到的结果,并不要求处理器真的把整个程序一条指令接一条指令地串行执行。 #### SB:双方都没有看到对方的写入? @@ -929,7 +929,7 @@ A 写入 x -> A 读取 y -> B 写入 y -> B 读取 x -> A 写入 x 不同线程的操作仍然可以交错。A 的两个操作在前、B 的两个操作在前,或者两次存储都在两次加载之前,都能保留各线程内部的顺序,分别得到前面列出的 `(0, 1)`、`(1, 0)` 和 `(1, 1)`。 -这种“不能越过”的说法是理解内存次序的直观方式。标准约束的是程序可观察到的结果,并不要求编译器和处理器完全按照源码顺序执行每条指令;只要保持这些保证,实现仍然可以进行优化。相关描述可参阅 [cppreference 的内存次序文档](https://zh.cppreference.com/cpp/atomic/memory_order)。 +这种“不能越过”的说法是理解内存次序的直观方式。标准约束的是程序可观察到的结果,并不要求编译器和处理器完全按照源码顺序执行每条指令;只要保持这些保证,实现仍然可以进行优化。 ### 宽松定序 From a55cf0165d1e3b8d3bd05e3fa7142d51227d8e8f Mon Sep 17 00:00:00 2001 From: Coned Zot Date: Wed, 16 Sep 2026 04:35:57 -0700 Subject: [PATCH 4/7] Add history of memory order --- ...16\345\216\237\345\255\220\346\223\215\344\275\234.md" | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git "a/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" "b/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" index 7f68b2f..faff83e 100644 --- "a/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" +++ "b/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" @@ -810,6 +810,8 @@ C++11 在引入线程和原子操作的同时,也引入了标准内存模型 随着编译器和硬件实现经验的积累,这套模型也经历了一些修订。本章采用 **C++20 及以后的释放序列(Release sequence)定义**,以及 **C++26 及以后的先序于(Sequenced-before)与先发生于(Happens-before)定义**,并说明 C++26 对 `consume` 的调整。一些旧资料使用的规则与现在不同,讲到具体内容时,我们会简单介绍采用这些标准的原因。 +C++ 内存模型的引入事实上是便随着 C++11 设计并发模型时引入的。在当时,首先考虑的是将 Java 的内存模型直接过来。但是,这个内存模型对 C++ 的类型系统,指针引用,`pthread` 库等不兼容。当时各种弱序内存模型的 CPU 的出现也使得这种内存模型没有宽松定序的问题暴露出来。在当时 HP 实验室的 Hans Boehm 推动下,他主导设计了 C++11 的内存模型。在 2024 年的一场谈话中,他承认了当时设计的一些问题(例如 `consume``),也指出,通常情况下,在现代 CPU 上使用这些复杂的定序带来的性能提升在逐渐降低,而在 GPU 上则还很重要。他还提到一个巨大的设计失误:在设计之初,他们刻意回避了所谓 out-of-thin-air 问题,这导致在极端情况下,C++ 允许读取操作读取到任何数据,而这一问题已经没有办法在现有的框架下修补(如今,所有的编译器都会保证这种情况在所有 CPU 上不会出现)。他还谈到,内存模型过于复杂,许多编译器作者也都没有阅读过 C++ 标准的这个部分。基于此,下文没有按照 C++ 的标准的顺序来讲解。 + ### 三组内存次序 `std::memory_order` 用来指定原子操作提供的内存次序。下面主要根据它们在线程之间提供的同步保证,比较三组内存次序: @@ -1115,6 +1117,10 @@ D 观察到的结果却要求两次写入具有相反的顺序: 这两组要求无法同时放进一个序列。相比各自建立的释放-获得定序同步关系,全局的序列一致次序提供了这一额外保证。 +IRIW 事实上是一个很有趣的测试。在 x86 最初的内存模型上,也就是 Intel 和 AMD 在 2007 年 8 月[^6],各自发布的手册上,都引入了 MP,SB 作为例子。但是,AMD 明确表示 IRIW 的上述情况可以出现,Intel 则语焉不详。在 2 年后的一场私下谈话中,一位 Intel 的工程师说,整个小组对认为 IRIW 的上述情况不可能在 Intel 的 CPU 上出现,但一致认为此处应该模糊处理,避开所有确定性的表述。即便在数百万次测试中,没有任何一个 CPU 上出现了 IRIW 的上述情况,大量程序不得不进行修改,JVM 的一些实现甚至因此大幅下降。直到 2008 年 11 月 Intel 才正式明确表示上述情况不可能出现,AMD 则在一年后的 2009 年 11 月才更新了类似的表述。如今,许多软件,包括一些 `mutex` 在 x86 上的实现都依赖于 IRIW,但是,在 C++ 的内存模型中,不同的顺序是可能出现的。 + +[^6]: 在这之前,Intel 在 2006 年 11 月发布了不带任何试金石测试的手册来介绍内存模型,并引发了不少误解。 + #### 三种定序放在一起看 下面汇总刚才讨论的结果。其中,释放-获得定序一列表示所有存储使用释放语义、所有加载使用获得语义;MP 也可以像前面的例子一样,对数据保留宽松定序访问。 @@ -1135,8 +1141,6 @@ D 观察到的结果却要求两次写入具有相反的顺序: 这里的“移到前面”或“移到后面”是帮助理解允许结果的方式,并不表示编译器或处理器一定真的交换了指令。C++ 规定的是程序可以观察到哪些结果。 -<-- 这里,我想加一些有意思的历史。 --> - #### `memory_order_acq_rel` 读修改写操作同时承担读取和写入两个角色:读取旧值,再写入新值。`std::memory_order_acq_rel` 为其中的读取提供获得语义,为写入提供释放语义。例如: From ab2c9a642330e05b635284eca24ed3f86d830c35 Mon Sep 17 00:00:00 2001 From: Coned Zot Date: Wed, 16 Sep 2026 04:41:08 -0700 Subject: [PATCH 5/7] fix a comment --- ...\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git "a/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" "b/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" index faff83e..4b41b28 100644 --- "a/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" +++ "b/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" @@ -804,7 +804,7 @@ RISC-V 采用的也是**弱序内存模型**(weakly-ordered memory model), 各位一定要区分,这种强弱其实也只是一种分类而已,不同的指令集架构大多都还是有所不同的,并不会完全一样。例如: `x86` 的 TSO(Total Store Order)是强一致性模型的一种,但并不是所有强一致性模型都是 TSO。 ### 一点历史 -<-- 很想多介绍些这段有意思的历史,不过整理这些资料有些费时。 --> + C++11 在引入线程和原子操作的同时,也引入了标准内存模型。这项设计的一大特点,就是通过可移植的语言接口,让开发者能够细致地选择内存次序。我们可以指定一个操作需要怎样的同步,而不用针对每种处理器分别编写指令序列。这种控制能力很适合用来实现底层并发设施,当然,它也并非 C++ 独有。 From 96b25fe273f7ac07a6c365e49882faba9862c426 Mon Sep 17 00:00:00 2001 From: Coned Zot Date: Wed, 16 Sep 2026 04:51:23 -0700 Subject: [PATCH 6/7] fix typo --- ...70\216\345\216\237\345\255\220\346\223\215\344\275\234.md" | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git "a/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" "b/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" index 4b41b28..f182561 100644 --- "a/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" +++ "b/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" @@ -810,7 +810,7 @@ C++11 在引入线程和原子操作的同时,也引入了标准内存模型 随着编译器和硬件实现经验的积累,这套模型也经历了一些修订。本章采用 **C++20 及以后的释放序列(Release sequence)定义**,以及 **C++26 及以后的先序于(Sequenced-before)与先发生于(Happens-before)定义**,并说明 C++26 对 `consume` 的调整。一些旧资料使用的规则与现在不同,讲到具体内容时,我们会简单介绍采用这些标准的原因。 -C++ 内存模型的引入事实上是便随着 C++11 设计并发模型时引入的。在当时,首先考虑的是将 Java 的内存模型直接过来。但是,这个内存模型对 C++ 的类型系统,指针引用,`pthread` 库等不兼容。当时各种弱序内存模型的 CPU 的出现也使得这种内存模型没有宽松定序的问题暴露出来。在当时 HP 实验室的 Hans Boehm 推动下,他主导设计了 C++11 的内存模型。在 2024 年的一场谈话中,他承认了当时设计的一些问题(例如 `consume``),也指出,通常情况下,在现代 CPU 上使用这些复杂的定序带来的性能提升在逐渐降低,而在 GPU 上则还很重要。他还提到一个巨大的设计失误:在设计之初,他们刻意回避了所谓 out-of-thin-air 问题,这导致在极端情况下,C++ 允许读取操作读取到任何数据,而这一问题已经没有办法在现有的框架下修补(如今,所有的编译器都会保证这种情况在所有 CPU 上不会出现)。他还谈到,内存模型过于复杂,许多编译器作者也都没有阅读过 C++ 标准的这个部分。基于此,下文没有按照 C++ 的标准的顺序来讲解。 +C++ 内存模型的引入事实上是伴随着 C++11 设计并发模型时引入的。在当时,首先考虑的是将 Java 的内存模型直接复制过来。但是,这个内存模型与 C++ 的类型系统,指针引用,`pthread` 库等不兼容。当时各种弱序内存模型的 CPU 的出现也使得这种内存模型没有宽松定序的问题暴露出来。在当时 HP 实验室的 Hans Boehm 的推动下,他主导设计了 C++11 的内存模型。在 2024 年的一场谈话中,他承认了当时的设计存在一些问题(例如 `consume``),也指出,通常情况下,在现代 CPU 上使用这些复杂的定序带来的性能提升在逐渐降低,而在 GPU 上则还很重要。他还提到一个巨大的设计失误:在设计之初,他们刻意回避了所谓 out-of-thin-air 问题,这导致在极端情况下,C++ 允许读取操作读取到任何数据,而这一问题已经没有办法在现有的框架下修补(如今,常见的编译器都会保证这种情况在所有 CPU 上不会出现)。他还谈到,内存模型过于复杂,许多编译器作者也都没有阅读过 C++ 标准的这个部分。基于此,下文没有按照 C++ 的标准的顺序来讲解。 ### 三组内存次序 @@ -1117,7 +1117,7 @@ D 观察到的结果却要求两次写入具有相反的顺序: 这两组要求无法同时放进一个序列。相比各自建立的释放-获得定序同步关系,全局的序列一致次序提供了这一额外保证。 -IRIW 事实上是一个很有趣的测试。在 x86 最初的内存模型上,也就是 Intel 和 AMD 在 2007 年 8 月[^6],各自发布的手册上,都引入了 MP,SB 作为例子。但是,AMD 明确表示 IRIW 的上述情况可以出现,Intel 则语焉不详。在 2 年后的一场私下谈话中,一位 Intel 的工程师说,整个小组对认为 IRIW 的上述情况不可能在 Intel 的 CPU 上出现,但一致认为此处应该模糊处理,避开所有确定性的表述。即便在数百万次测试中,没有任何一个 CPU 上出现了 IRIW 的上述情况,大量程序不得不进行修改,JVM 的一些实现甚至因此大幅下降。直到 2008 年 11 月 Intel 才正式明确表示上述情况不可能出现,AMD 则在一年后的 2009 年 11 月才更新了类似的表述。如今,许多软件,包括一些 `mutex` 在 x86 上的实现都依赖于 IRIW,但是,在 C++ 的内存模型中,不同的顺序是可能出现的。 +IRIW 事实上是一个很有趣的测试。在 x86 最初的内存模型上,也就是 Intel 和 AMD 在 2007 年 8 月[^6],各自发布的手册上,都引入了 MP,SB 作为例子。但是,AMD 明确表示 IRIW 的上述情况可以出现,Intel 则语焉不详。在 2 年后的一场私下谈话中,一位 Intel 的工程师说,整个小组都认为 IRIW 的上述情况不可能在 Intel 的 CPU 上出现,但又一致认为此处应该模糊处理,避开所有确定性的表述。即便在数百万次测试中,没有任何一个 CPU 上出现了 IRIW 的上述情况,大量程序不得不进行修改,JVM 的一些实现甚至因此性能大幅下降。直到 2008 年 11 月 Intel 才正式明确表示上述情况不可能出现,AMD 则在一年后的 2009 年 11 月才更新了类似的表述。如今,许多软件,包括一些 `mutex` 在 x86 上的实现都依赖于 IRIW,但是,在 C++ 的内存模型中,不同的读取顺序是可能出现的。 [^6]: 在这之前,Intel 在 2006 年 11 月发布了不带任何试金石测试的手册来介绍内存模型,并引发了不少误解。 From b1cf20c4a2397629f0965ca10ad9e02d5f2dedb5 Mon Sep 17 00:00:00 2001 From: Coned Zot Date: Wed, 16 Sep 2026 04:55:38 -0700 Subject: [PATCH 7/7] small edit --- ...\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git "a/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" "b/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" index f182561..81f3506 100644 --- "a/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" +++ "b/md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.md" @@ -1204,7 +1204,7 @@ std::atomic data{ 0 }, state{ 0 }; **目前不要使用 `std::memory_order_consume`,请改用 `std::memory_order_acquire`。** -消费定序(Consume)最初希望为依赖于加载值的后续访问提供更弱的定序,例如通过刚读取的指针访问已经发布的对象。不过,在编译器优化过程中保留这些依赖关系很困难。GCC 的[原子内建函数文档](https://gcc.gnu.org/onlinedocs/gcc/_005f_005fatomic-Builtins.html)明确说明,其消费定序使用获得语义来实现。事实上,在任何一个主流的 C++ 编译器,都从未实现过消费定序。这些编译器都将消费定序直接替换为释放定序。基于此,在 C++26 工作草案中,消费定序已被弃用,并具有获得语义,见[兼容性规范](https://eel.is/c++draft/depr.atomics.order)。另外,得利于消费定序的删除,有关同步于,先发生于的定义都被大幅简化,本文因此采用 C++26 的定义。 +消费定序(Consume)最初希望为依赖于加载值的后续访问提供更弱的定序,例如通过刚读取的指针访问已经发布的对象。不过,在编译器优化过程中保留这些依赖关系很困难。GCC 的[原子内建函数文档](https://gcc.gnu.org/onlinedocs/gcc/_005f_005fatomic-Builtins.html)明确说明,其消费定序使用获得语义来实现。事实上,在任何一个主流的 C++ 编译器上,从未实现过消费定序。这些编译器都将消费定序直接替换为释放定序。基于此,在 C++26 工作草案中,消费定序已被弃用,并具有获得语义,见[兼容性规范](https://eel.is/c++draft/depr.atomics.order)。另外,得利于消费定序的删除,有关同步于,先发生于的定义都被大幅简化,本文因此采用 C++26 的定义。 ### 与 `volatile` 的关系