内存顺序与同步关系

读完能用自己的话解释什么是重排序、为什么 relaxed 原子不保证跨变量顺序,能说清 acquire-release 配对如何在两个线程之间建立 happens-before,并知道 relaxed / acquire-release / seq_cst 三档内存序各保证什么、默认是哪一档。

学习目标

  • 能解释重排序memory_order_relaxed 的边界,并判断 relaxed 标志配普通 payload 为什么会产生数据竞争
  • 能绘制 acquire-release 的证明链:sequenced-before、reads-from、synchronizes-with 到 happens-before,并区分 relaxed、acquire-release 与 seq_cst
  • 能回答这个检验问题:一个线程用 relaxed 把数据写好后置位标志,另一个线程用 relaxed 读到标志为真——这能保证标志之前的数据对读到标志的线程可见吗?为什么必须改成 acquire-release?

机制总览

release/acquire 如何建立 happens-before

  1. 1

    发布数据

    普通写在 release store 之前 sequenced-before。

  2. 2

    读取标志

    acquire load 必须读到 release 或其 release sequence 中的值。

  3. 3

    推导可见性

    synchronizes-with 连接两线程,再由传递性形成 happens-before。

先按顺序建立机制,再进入实验切换阶段并检查失效证据。

章级决策实验

release/acquire 如何建立 happens-before

沿发布线程和消费线程推导 sequenced-before、synchronizes-with 与可见性。

选择推理阶段

当前阶段 · 发布数据

普通写在 release store 之前 sequenced-before。

可核验证据

线程内顺序、store memory order 与写集合。

内存序必须从跨线程不变量反推;只有读到对应 release 值的 acquire 才建立同步边。

失效—证据矩阵

release/acquire 如何建立 happens-before

发布数据

典型失效

用 relaxed 标志发布非原子数据,消费者无可见性保证。

核验证据

线程内顺序、store memory order 与写集合。

读取标志

典型失效

只看到标志为真就假设同步,却没有来源关系。

核验证据

read-from 关系与观测值。

推导可见性

典型失效

把 seq_cst 当性能标签,或在没有同步边时谈跨变量顺序。

核验证据

happens-before 图、litmus test 与汇编检查。

每个判断都必须能落到观测、测试或产物,不能只凭代码表面推测。

从一条跨线程可见性证据开始

后厨两个厨师各有一张「动作清单」,照着上面的先后顺序干活。甲的清单写着:先把菜备好,再喊一声「这桌齐了」。可清单只是他自己的先后;店里另一个人扭头去看时,看到的「实际发生顺序」未必和清单一致——为了省事,有人可能先听见「齐了」、再回头才看见菜其实还没摆全。照着这个错位的观察去端盘子,准出乱子。

这一章要解决的,正是「怎么让一个厨师做的事,按他想要的先后、可靠地被另一个厨师看到」。光让每个动作本身「不可分割」(上一章的原子)还不够——多线程的麻烦常常在于多件事之间的先后和可见,而不是单独一件事。

后厨的解法很朴素:装一台对讲机。甲把菜全摆好之后按一下对讲机,乙听到对讲机才开工。这一按一听之间连出一条「专线」:乙一旦听到,就保证甲按之前做的事全都摆好了、且乙看得见。没有这条专线会怎样?乙凭感觉抢跑,照着半成品干活——读到的全是没准备好的乱数据。这一章就教你怎么在代码里「按对讲机、听对讲机」,把可靠的先后建起来。

重排序:你写的顺序,不是别人看到的顺序

上一页讲过,每个原子对象都有自己的修改顺序。但 x 的顺序不会自动与 y 的顺序拼成全局时间线,普通对象也不会因为旁边放了一个 relaxed 标志就自动获得同步。

编译器、CPU 和缓存系统可以在不破坏单线程可观察行为的前提下改变执行或传播顺序。这种「源码顺序不等于另一个线程可证明的观察顺序」就是 。另一个线程若没有同步边,不能只凭源码位置或墙钟直觉推导可见性。

适合不承担发布职责的独立计数或统计。它不是“随便乱读”:同一原子对象仍受修改顺序和一致性规则约束;它缺少的是把其他对象带过线程边界的 synchronizes-with。下面的动画展示两个 relaxed 原子对象之间没有发布关系:

猜一猜:x、y 初值都是 0,全用 relaxed。线程1 写 x=1; y=1;,线程2 读 r1=y; r2=x;。线程2 如果读到 r1==1(看到了 y 的新值),那它读 x 时还会读到 r2==0(旧值)吗?直觉觉得「y 都新了,x 肯定也新了」——先想,再单步看。

看清楚了:左栏是你写的源码顺序,右栏是线程2 在 relaxed 下实际可能看到的顺序——它看到 y=1(于是 r1=1),却还没看到 x=1(于是 r2=0)。直觉中「y 新了 x 必然也新」的推断,依赖的是「x 的写排在 y 的写之前、且对线程2 可见」,但 relaxed 根本不给这个保证。这就是我们需要更强内存序的全部理由。

三档内存序:从最弱到最强

C++ 给原子操作提供 参数。先用三组能力建立骨架:

  • 最弱:relaxed。只保证单变量原子性 + 单变量修改顺序,不管跨变量顺序与可见性(上面刚演过)。适合「只累加、不据此推断可见性」的场景,如纯计数器。
  • 发布与获取:acquire-release 家族 中,load 可用 acquire,store 可用 release,读改写可用 acquire、release 或 acq_rel。它们按 read-from 关系配对,不是简单按“一个写、一个读”配对。
  • 顺序一致:seq_cst 为所有 seq_cst 操作与 fence 增加一个单一总序,通常最容易推理,但不能脱离实际操作类型和 read-from 关系只靠总序猜结果。

记住一句口诀:relaxed 只管单变量,acquire-release 管「配对的两个线程」,seq_cst 管「全店一致」

关系三件套:sequenced-before、synchronizes-with、happens-before

要精确说清「谁先于谁、谁对谁可见」,C++ 内存模型用三个关系搭起一套「能顺着走的路网」。它们是本章最抽象、也最关键的概念,务必对着下面的有向图理解。

第一个是 。它来自语言求值顺序,而不是“所有写在左边的表达式都更早”。本章使用独立完整语句,因此 data = 42 sequenced-before 后续 release store。

第二个是 。它并非只来自原子:线程启动与 join、mutex 和 condition variable 等标准库操作也定义同步。本页聚焦 release/acquire 这一种证据。

第三个是 。若对同一普通对象的冲突访问没有 happens-before,程序可能数据竞争;建立路径后,还要列出该对象是否有其他介入写入,才能判断具体读值。下面的图展示最常用的简化路径:

猜一猜:图里线程1 的 A1(写 data)和线程2 的 B2(读 data)分属两个线程,中间隔着一条 release→acquire 的同步边。顺着 A1→A2⇢B1→B2 这条路,能不能从 A1「走到」B2?如果能,是不是就意味着 A1 一定先于 B2、且 data 对 B2 可见?先想,再单步看高亮路径。

可交互
happens-before = 顺着边能走到线程1线程2sequenced-beforeA1: data = 42普通写A2: ready.store(release)发布B1: ready.load(acquire)读到 trueB2: 读 data拿到 42synchronizes-withhappens-before 路径:A1 → A2 ⇢ B1 → B2(A1 一定先于 B2 且可见)

第 1 / 7 步 · 线程1 的 A1:普通写 data = 42

实线灰 = sequenced-before(同线程源码先后);彩色虚线 = synchronizes-with(release→读到它的 acquire)。最后高亮的整条路径 = happens-before(顺着边能走到)。可暂停、单步、拖进度。

happens-before 是 sequenced-before(同线程内)与 synchronizes-with(跨线程 release→acquire)的传递闭包:只要顺着这些边能从一个操作走到另一个,前者就一定先于后者、且其成果对后者可见。图中 A1 经同步边可达 B2,故 A1 happens-before B2。

图中没有其他 data 写入,所以 A1 happens-before B2 后,B2 读取 A1 写入的值。一般证明不能只写“可见”,还要确认是否有另一个允许被观察的 side effect 介入。

acquire-release 配对:标志位 + 数据的经典模式

现在把对讲机真正接上线。最经典、也最该背下来的,是「标志位 + 数据」发布模式:生产者把数据写好,再置一个「就绪」标志;消费者等标志变真,再去读数据。关键在于:标志的写用 release、标志的读用 acquire,靠这对配对建立同步专线,让数据对消费者可见。这就是本章的核心 Demo:

猜一猜:如果把这套模式里的 ready 改成全程 relaxed(不用 acquire-release),消费者读到 ready==true 之后,去读 data 一定能拿到生产者写的 42 吗?还是可能读到半成品?先想,再播放看答案。

可交互
acquire-release 配对:一按一听,连出同步专线线程1(生产)线程2(消费)① data = 42普通写(把菜备好)② ready.store(true, release)按对讲机:发布③ while(!ready.load(acquire))听到对讲机才开工(读到 true)④ 读 data → 必得 42取菜:拿到的一定是新值同步专线synchronizes-withdata=42 此刻对线程2 可见 ✓

第 1 / 4 步 · ① 线程1 先普通写 data = 42——这只是写进内存,还没「发布」

① 写 data → ② release 存 ready(按对讲机)→ ③ acquire 读到 ready(听到对讲机,此刻接通同步专线)→ ④ 读 data 必得 42。正是这条专线让 ① happens-before ④。可暂停、单步、拖进度。

acquire-release 配对:release 存与「读到该值的」acquire 读之间建立 synchronizes-with(同步专线)。这条专线让 release 之前的所有写(含 data=42)都对 acquire 之后可见——所以线程2 读到的一定是 42。这正是「标志位 + 数据」发布模式的原理。

逐拍看懂这条专线:① 生产者普通写 data=42;② 用 releaseready=true(按对讲机,把 ① 的写一并「封口发布」);③ 消费者用 acquire 读、真的读到 ready=true(听到对讲机)——正是这一拍,② 与 ③ 之间接通 synchronizes-with;④ 消费者读 data,因为这条专线让 ① happens-before ④,所以读到的一定是 42

这里有三个必须记牢的要点:第一,必须配对——只 release 不 acquire、或反过来,都连不出专线。第二,acquire 必须真的读到 release 写的那个值;如果读到的还是旧值(说明它跑在 release 之前),同步专线就不接通——所以消费者通常要循环等到 acquire 读到「就绪」才往下走。第三,可见性是单向的:release「往下封口」(它之前的写对外发布),acquire「往上开闸」(它之后的读能看见),方向正是「release 之前 → acquire 之后」,对发布模式刚好够用。

这张图也是审查清单:找不到“谁读到了谁”的 read-from 证据,就不能从两个看似成对的内存序推导 synchronizes-with;找不到完整 happens-before 路径,就不能安全访问普通 payload。

release sequence 与 consume 的 C++17 边界

让同步可以穿过中间的原子读改写。线程 A release-store 1,线程 B 用 relaxed fetch_add 改成 2,线程 C acquire-load 读到 2;在 C++17 中,C 仍可与 A 的 release 同步。它依赖同一原子对象的修改顺序,普通 store 不能任意插入后仍保留链。

C++17 还有 。它试图只约束依赖于加载结果的后续操作,但依赖链在现实优化中难以稳定表达,主流实现通常将其提升为 acquire。新代码在没有工具链级证据时直接使用 acquire。

seq_cst 与内存栅栏:全店一块中央大屏

acquire-release 只在「配对的那一对」之间建立局部顺序;不同线程对没有配对关系的操作,可能看到不一致的相对顺序。当你需要「全店对一组操作看到完全一致的先后」时,就上最强的 seq_cst。

所有 seq_cst 操作与 fence 参加一个满足标准一致性约束的单一总序;该顺序还必须与相关修改顺序和 happens-before 约束相容。它是原子操作的默认内存序,在部分硬件上可能需要更强指令,但性能差异必须测量。拿不准时先用 seq_cst,只有完成正确性证明并测得收益后才降级。

最后认识 。fence 只约束它前后的操作,两道 fence 彼此不会凭空同步。常见 fence-fence 模式要求 release fence 后有原子写 X;另一线程的原子读 Y 读到 X 写入的值,且 Y sequenced-before acquire fence。缺少这条载体关系,两个 fence 只是各在线程里立栏。

上手玩一玩这三张图

这三张图都是可操作的教具——单步、暂停、拖进度,配合下一节代码一起玩,比读十遍文字管用:

  • acquire-release 同步专线 <AcquireReleaseSyncDiagram />(主图):重点盯第 ③ 拍——消费者 acquire 真的读到 ready=true那一瞬,② 与 ③ 之间才弹出「同步专线」。再看第 ④ 拍,底部高亮「data=42 对线程2 可见」。把这条专线「何时出现、连接谁与谁」看透,acquire-release 就懂了。
  • happens-before 路网 <HappensBeforeDAG />:单步走完节点和边,最后看高亮的整条路径 A1→A2⇢B1→B2。体会「happens-before = 顺着边能走到」这件事;并想清楚 relaxed 为什么就是缺了那条彩色虚线(跨线程同步边)。
  • 重排序对照 ``:盯右栏——线程2 看到 r1=1r2=0 的反直觉结果,正是 relaxed 不保证跨变量顺序/可见性的真身。

玩熟这三张图,下一节每一句 release / acquire / relaxed / atomic_thread_fence 你都能对上图里某条边、某个动作。

代码:把内存序一段段写出来

relaxed 重排:你以为的先后不成立

先把上面那个反直觉例子写成可编译的代码。两端全用 relaxed,断言可能失败:

#include <atomic>
#include <thread>
#include <cassert>
 
std::atomic<int> x{0}, y{0};
 
void writer() {
    x.store(1, std::memory_order_relaxed); // 写 x
    y.store(1, std::memory_order_relaxed); // 写 y
}
 
void reader() {
    int r1 = y.load(std::memory_order_relaxed); // 先读 y
    int r2 = x.load(std::memory_order_relaxed); // 再读 x
    // relaxed 不建立跨对象同步:r1==1 && r2==0 是允许结果
    assert(!(r1 == 1 && r2 == 0)); // 故意可能失败的反例断言
}

这段只访问原子对象,所以没有数据竞争;它展示的是 relaxed 允许的结果集合。不要把故意可能失败的 assert 留在生产程序里,测试也不能靠“运行很多次没失败”证明内存模型性质。

acquire-release:标志位 + 数据发布模式

把对讲机接上。生产者写 data 后用 release 发布标志,消费者用 acquire 等到标志再读 data

#include <atomic>
#include <thread>
#include <cassert>
 
int data = 0;             // 普通变量(非原子)
std::atomic<bool> ready{false};
 
void producer() {
    data = 42;                                       // ① 普通写:把数据备好
    ready.store(true, std::memory_order_release);    // ② release 发布:按对讲机
}
 
void consumer() {
    while (!ready.load(std::memory_order_acquire)) { // ③ acquire 等到读到 true
        // 自旋等待:必须循环到真的读到 true,同步专线才接通
    }
    assert(data == 42);                              // ④ 无其他写时必为 42
}

② 与读到该值的 ③ synchronizes-with,① 因同线程顺序 happens-before ④。这里 data 只有一次写,所以断言成立且普通访问没有数据竞争。若两处都改 relaxed,① 与 ④ 无 happens-before,它们构成数据竞争,程序行为未定义,不只是“偶尔读到 0”。

seq_cst:默认档,全局全序

不显式写内存序时,原子操作默认就是 seq_cst——最强、最省心:

#include <atomic>
 
std::atomic<bool> ready{false};
 
void producer_seqcst() {
    ready.store(true); // 等价于 ready.store(true, std::memory_order_seq_cst)
}
 
bool consumer_seqcst() {
    return ready.load(); // 同样默认 seq_cst:所有 seq_cst 操作共享唯一全局全序
}

不带内存序参数的原子操作默认 seq_cst。它们参加单一总序,并继续满足各自操作类型的 acquire 或 release 语义。是否比 acquire-release 显著昂贵取决于目标架构与代码形态,先保证正确,再用测量决定是否降级。

内存栅栏:把序约束单独下

std::atomic_thread_fence 把内存序从「绑在某次 store/load」抽出来,单独立一道栏:

#include <atomic>
#include <thread>
 
int data = 0;
std::atomic<bool> ready{false};
 
void producer_fence() {
    data = 42;
    std::atomic_thread_fence(std::memory_order_release); // 立一道 release 栏
    ready.store(true, std::memory_order_relaxed);        // 标志本身用 relaxed 即可
}
 
void consumer_fence() {
    while (!ready.load(std::memory_order_relaxed)) {
        std::this_thread::yield();
    }
    std::atomic_thread_fence(std::memory_order_acquire); // 立一道 acquire 栏
    // 栏之后读 data 保证可见
}

关键不是“两道栏看起来成对”,而是 consumer 的 relaxed load 读到 producer relaxed store 写入的 true。这条原子 read-from 连接位于两道 fence 之间,才让 release fence synchronizes-with acquire fence;随后读取 data 才有定义。

容易踩的坑

小结

  • 重排序:你写的源码顺序 ≠ 别人观察到的顺序;编译器/CPU 可重排、写也未必及时可见——单线程无害,多线程才暴雷
  • relaxed 只保证单变量原子性 + 单变量修改顺序,保证跨变量的顺序与可见性(读到标志为真,推不出标志之前的数据已可见)
  • 证明链:同线程 sequenced-before + read-from 触发的 synchronizes-with + 传递性建立 happens-before;普通 payload 必须沿链有序
  • acquire-release 配对:release 存与「真的读到它」的 acquire 读之间建 synchronizes-with,让 release 之前的写对 acquire 之后可见——这就是「标志位 + 数据」发布模式,必须配对、acquire 必须真读到
  • 工具边界:seq_cst 默认加入单一总序;release sequence 可把同步穿过 RMW 链;fence 必须依赖原子 read-from 载体,consume 在实践中通常提升为 acquire

练习

问题 1(判断/解释型) 同事用 relaxed 原子标志发布普通 data,并说读到标志就能安全使用数据。这话对吗?请判断是否存在数据竞争,画出缺失的同步边并给出修法。

问题 2(改代码型) 下面代码想让线程 2 看到 flag==1 后安全读取 payload,但使用 relaxed。指出数据竞争并改成可证明成立的版本。

std::atomic<int> flag{0};
int payload = 0;
 
void t1() {
    payload = 100;
    flag.store(1, std::memory_order_relaxed); // ❌
}
 
void t2() {
    while (flag.load(std::memory_order_relaxed) == 0) {} // ❌
    // 这里读 payload,期望它是 100,但不保证
    int p = payload;
}

问题 3(问答型) 解释:(a) 为什么单独一个 release 建立不了同步;(b) seq_cst 增加了什么总序;(c) 为什么 release fence 与 acquire fence 之间仍需要原子 read-from 载体?

名词解释

本章出现的专业名词,用大白话再讲一遍。

重排序(reordering)

你写的源码顺序是 A→B,但编译器或 CPU 为了跑得快,让它们实际以 B→A 的顺序执行,或让一个线程的写不及时对另一个线程可见。单线程内重排不影响本线程结果;多线程下,旁观的另一个线程可能观察到反直觉的顺序。像两个厨师各有「动作清单」,旁观者看到的实际发生顺序却被打乱。详见本章「重排序」一节。

内存序(memory order)

给原子操作附加的参数,规定它在跨变量的顺序与可见性上要遵守多强的约束。从弱到强分三档:memory_order_relaxed、acquire-release 家族、memory_order_seq_cst。档越强、保证越多、通常开销越大;不写时默认 seq_cst。详见本章「三档内存序」一节。

memory_order_relaxed

最弱的一档内存序。只保证单个原子变量的原子性(不撕裂)和该变量自己的修改顺序(单变量全序);保证跨多个变量的顺序,也保证一个线程的写及时对另一个线程可见。所以用 relaxed 读到某标志为真,推不出「标志之前写的别的数据」也对你可见。详见本章「重排序」一节。

acquire-release

中间一档内存序,含 memory_order_acquire(给读用)、memory_order_release(给写用)、memory_order_acq_rel(给既读又写的操作用)。核心能力是配对建立同步:一个 release 存与「真的读到它写的值」的 acquire 读之间建立 synchronizes-with,让 release 之前的写对 acquire 之后可见。像「按对讲机 / 听对讲机」。详见本章「acquire-release 配对」一节。

memory_order_seq_cst

最强、也是默认的内存序。除了具备 acquire-release 的同步能力,还额外保证所有 seq_cst 操作存在唯一一个全局全序、且所有线程看法完全一致——像全店一块「中央大屏」,所有 seq_cst 操作按同一顺序登记上屏,谁看都一样。最不容易出错,代价是开销更大。详见本章「seq_cst 与内存栅栏」一节。

sequenced-before

同一线程内由 C++ 求值顺序规则定义的关系。A sequenced-before B 表示 A 的求值完成先于 B;它不等同于任意表达式按文本从左到右求值。

synchronizes-with

标准定义的跨线程同步关系。原子情形中,acquire 读取 release 或其 release sequence 的值可建立该关系;mutex、线程生命周期等库操作也定义 synchronizes-with。

happens-before

判断冲突访问是否有序的核心关系。本章 acquire-release 子集中可由 sequenced-before、synchronizes-with 和传递性建立;C++17 完整模型还包含依赖关系规则。

release sequence

以 release 操作为首、沿同一原子对象修改顺序延伸的链。C++17 允许随后同线程写与任意线程 RMW 加入;C++20 起只保留连续 RMW。acquire 读到链中值可与序列头同步。

memory_order_consume

C++17 中基于数据或地址依赖传播顺序的内存序。主流实现通常将它加强成 acquire;没有工具链级证明时,新代码直接使用 acquire 更可审查。

内存栅栏(fence)

std::atomic_thread_fence 在不直接访问原子对象的情况下约束前后操作。release 与 acquire fence 不会自行同步,必须由同一原子对象的写与读提供 read-from 连接。

讨论

评论区加载中…