﻿# 编译器重排（Compiler Reordering）—— 编译期就发生的第一层乱序

> [reordering-overview.md](/concepts/memory-ordering/reordering-overview.md) 把"重排"拆成三层：**编译器重排(编译期) / CPU 乱序执行(运行期·核内) / 内存重排(运行期·跨核)**。第2层有 [cpu-out-of-order.md](/concepts/microarch/cpu-out-of-order.md)、第3层有 [memory-reordering.md](/concepts/memory-ordering/memory-reordering.md)，**独缺第1层的专篇**——本篇补上。


> 编译器重排常被忽略，因为它"太早"发生：在 CPU 还没跑之前，编译器就已经把你的代码顺序改了。它和硬件重排是**完全独立**的成因——关掉所有 CPU 乱序、换成最老实的顺序执行核，编译器重排照样能让你的多线程代码出错。本篇讲清：**编译器为什么重排、具体做哪些、为什么单线程无感多线程出错、`volatile` 到底管不管用、以及怎么正确拦它。**

## 一、根据 as-if 规则：编译器只保证"单线程结果不变"

C/C++ 标准给编译器一条核心授权——**as-if 规则(as-if rule)**。名字直译是"仿佛规则":编译器生成的程序只要**表现得"仿佛"(as if)严格按源码一条条执行过**就行,底下怎么改、改成什么样,标准一概不管。

> **as-if 规则**:编译器可以对程序做**任何变换**(换序/合并/删除/缓存/并行化……),只要变换后的程序的**可观测行为(observable behavior)**和"抽象机严格按源码执行"时一致即可。

这条规则出自 C++ 标准 `[intro.abstract]`(旧称 `[intro.execution]`),原文大意是:"符合标准的实现只需**模拟抽象机的可观测行为**"。C 标准 §5.1.2.3 有等价条款。换句话说,标准定义了一台"抽象机(abstract machine)"——它老老实实按你写的顺序执行;而真实编译器只要**对外表现和这台抽象机一样**,内部就是自由的。

### "可观测行为"到底指哪些?——标准只认这三类

as-if 的全部约束,浓缩成"可观测行为"这个词。标准把它**精确限定**为下面三类,**除此以外的一切都可被优化掉**:

| 可观测行为(编译器必须保住) | 说明 |
|------|------|
| **① `volatile` 对象的访问** | 每次 `volatile` 读/写都必须真发生、且保持相互顺序(正是第四节 `volatile` 的语义来源) |
| **② 程序终止时写入文件/设备的数据** | 即 I/O:`printf`、写文件、网络发送等——最终产生的字节序列必须和抽象机一致 |
| **③ 交互设备的输入输出时序** | 提示信息必须在等待输入**之前**真的送达(所以带 I/O 的循环编译器不敢乱删/乱调) |

> **反过来读这张表最有用**:凡是**不属于**这三类的东西——普通变量的中间值、局部计算、临时对象、甚至一段没有 I/O 的循环——**编译器都有权改写或抹掉**,只要最终的 ①②③ 对得上。这就是为什么 `sum += i` 的纯计算热循环能被整段删除(见第六节)、为什么 `while(!flag)` 的空自旋能被优化没(第二、六节):它们不产生任何可观测行为。

C/C++ 标准给编译器一条核心授权——**as-if 规则**:

> 编译器可以对程序做**任何变换**，只要变换后的程序**在单线程下的可观测行为**和原程序一致。

"可观测行为"指:程序的最终输出、`volatile` 访问、I/O 这些。**在这个约束内，编译器可以自由调换、合并、删除、缓存你的读写**——只要单线程看不出区别。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<src>> #E3F2FD
  BorderColor<<src>>     #1976D2
  BackgroundColor<<opt>> #FFF9C4
  BorderColor<<opt>>     #F9A825
  BackgroundColor<<out>> #C8E6C9
  BorderColor<<out>>     #388E3C
}
rectangle "你写的源码顺序\na = 1;\nb = 2;" <<src>> as S
rectangle "编译器 (as-if):\n只要单线程结果一样,\n随便调换/合并/删除/缓存" <<opt>> as O
rectangle "生成的机器码顺序\n可能是 b=2; a=1;\n(a、b 无依赖,换序单线程无影响)" <<out>> as M
S -right-> O
O -right-> M
note bottom of O : 关键:as-if 只管"单线程"\n它**根本不知道**有别的线程在看
@enduml
```

> **核心认知**：as-if 是**单线程契约**。编译器优化时**假设代码是单线程的**——它看到 `a`、`b` 之间没有数据依赖，就认为换序绝对安全。**它意识不到另一个线程可能正盯着 `a` 和 `b` 的先后。** 这就是编译器重排在多线程下闯祸的根源。

## 二、编译器具体做哪些"重排"

"编译器重排"是个统称，实际包含好几类变换。它们都在 as-if 授权内，单线程都无害：

| 类型 | 做什么 | 例子 |
|------|--------|------|
| **语句换序(reordering)** | 调换无依赖的独立读写 | `a=1; b=2;` → `b=2; a=1;` |
| **寄存器缓存(hoisting)** | 把内存变量读进寄存器、循环里不再重读 | `while(!flag){}` → `if(!flag) while(true){}`(flag 只读一次!) |
| **死代码消除(DCE)** | 删掉"没有可观测效果"的读写 | 写了从不读的变量 → 整条删掉 |
| **合并(coalescing)** | 多次写合成一次 | `x=1; x=2;` → `x=2`;相邻小写合成宽写 |
| **提升/下沉(hoist/sink)** | 把循环内不变的计算移到循环外 | 循环不变量外提 |

最危险、最常咬人的是**寄存器缓存**——看这个多线程等待循环：

```c
bool flag = false;   // 普通变量,另一个线程会置 true
// 线程 A 等待:
while (!flag) { }    // 你以为:反复读内存里的 flag,直到别人改成 true
// 线程 B: flag = true;
```

- **你的意图**:每次循环重新读 `flag`，直到看到 true 就退出。
- **编译器怎么想**:as-if 下它认定"这循环体里没人改 `flag`"，于是**把 `flag` 读进寄存器一次**，循环里只看寄存器——生成的等价代码是:

```c
if (!flag)           // 只读一次内存
    while (true) { } // flag 在寄存器里永远是 false → 死循环!
```

- **后果**:哪怕线程 B 真把内存里的 `flag` 改成了 true，线程 A **永远看不到**——它在看一个缓存在寄存器里的旧值。**死循环。** 而且这**完全不涉及 CPU 缓存或多核可见性**，是编译器一家干的——关掉多核、单核跑也一样死。

用时序图看清"寄存器里的旧值和内存里的新值分道扬镳"：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  LifeLineBorderColor        #90A4AE
}
participant "线程A\n(CPU 寄存器)" as RA
participant "内存\nflag" as M
participant "线程B" as B
note over M : flag = false
RA -> M : ① 循环**前**读一次 flag → 装进寄存器\n(编译器把 load 提到循环外)
note over RA : 寄存器 rflag = false
loop 循环体:只看寄存器,不再碰内存
  RA -> RA : ② 判断 rflag==false? → 是 → 继续\n(压根不发 load 指令)
end
B -> M : ③ flag = true(改的是**内存**)
note over M : flag = true ✅
note over RA : 寄存器 rflag 仍 = false ❌\n(没人去内存重读)
RA -> RA : ④ 还在看 rflag==false → **死循环**
note over RA, M : 内存新值 与 寄存器旧值 分道扬镳\n——纯编译期优化造成,与 CPU 缓存/多核无关
@enduml
```

### 等等——缓存一致性协议(MESI)不是保证一致吗？为什么救不了？

这是最常见的困惑：**既然有 MESI（[mesi.md](/concepts/cache/mesi.md)）保证多核缓存一致，线程B 改了 `flag`，线程A 不是该看到最新值吗？** 关键在于——**MESI 管不到寄存器，寄存器根本不在它的管辖范围内。**

- **MESI 保证的是"各核缓存副本 ↔ 内存"这一层的一致**。它嗅探总线，让同一 cache line 的多份缓存副本对外像一份。但**寄存器不是缓存**——它是 CPU 核内的私有存储，MESI 嗅探时**根本看不见"某个值被 load 进了寄存器"**，无从通知它更新，寄存器压根不参与一致性协议。
- **更要命的是**：优化后线程A 的循环体**根本不再发 load 指令**了——`flag` 只在循环前被读进寄存器一次，之后每轮只看寄存器 `rflag`，**完全不碰缓存也不碰内存**。MESI 再尽职，也需要你"去读缓存"这个动作才有机会保证一致；你不读，它无从发力。

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  LifeLineBorderColor        #90A4AE
}
participant "线程A\n寄存器 rflag" as RA
participant "线程A\nL1 缓存" as CA
participant "内存/总线" as M
participant "线程B" as B
B -> M : flag = true
M -> CA : ✅ MESI 尽职:把 A 缓存里 flag\n那条 cache line 置 Invalid
note over CA : 缓存副本确实失效了\n下次读会拿到新值
RA -> RA : ❌ 但循环体只看寄存器 rflag\n**压根不发 load 去读缓存**
note over RA, CA : MESI 那条链是通的,\n可惜没人去"读缓存"触发它
@enduml
```

三层各归谁管，一眼看清 MESI 的边界：

| 层次 | 谁保证一致 | 本例里 |
|------|-----------|--------|
| 各核缓存副本 ↔ 内存 | **MESI（硬件）** | ✅ 正常工作：`flag` 的 cache line 被正确失效 |
| **缓存 ↔ 寄存器** | **没有任何硬件协议**——由编译器决定何时 load/store | ❌ 出事点：编译器只 load 一次，之后不再同步 |

> **一句话**：MESI 保证的是"**当你去读缓存时**，读到的是最新值"；但编译器优化把 load 提走后，线程A **根本不去读了**。缓存一致性是"缓存/内存这一层"的事，**寄存器高它一层、是编译器的地盘，硬件协议够不着**。这也正是 `volatile` 为什么能治它——`volatile` 强制**每次访问都真发 load 指令(绕开寄存器、去读缓存)**，一旦重新发 load，MESI 那条链就接上了，自然读到最新值(但 `volatile` 只补上"读寄存器→读缓存"这一环，多核间的乱序可见性它仍管不了，见第四节)。

### 这真的会发生吗？—— 会，而且很容易复现

不是理论假想。**普通(非 `volatile`/非原子)全局变量 + 自旋等待循环，在 `-O2` 下几乎必然被这样优化**——这正是历史上 `volatile`、如今 `std::atomic` 必须存在的经典理由。

- **怎么亲眼看到**：把上面的代码在 [Compiler Explorer](https://godbolt.org) 上用 `gcc -O2` 编译，或本地 `g++ -O2 -S`（见 [../elf/objdump.md](/concepts/elf/objdump.md)）。你会看到 `-O0` 版循环体里每轮都有一条 `mov`(从内存读 flag)，而 `-O2` 版把 load 提到循环外、循环体退化成一条无条件 `jmp .`（原地死跳）——**铁证**。
- **触发条件**：编译器要能"证明"循环体内没人改 `flag`。对一个循环体里不写 `flag`、也没调用不透明函数(它看不穿的函数可能改全局)的循环，它就敢把 load 提出去。所以：
  - **很可能死循环**：`while(!flag){}` 空循环体、或循环体只做与 `flag` 无关的纯计算。
  - **可能"碰巧"不死**：如果循环体里调了一个编译器**看不见实现**的函数(如 `printf`、跨编译单元的函数)，编译器不敢假设 `flag` 没变，被迫每轮重读——于是碰巧"能跑对"。**但这是运气,不是正确**：换个编译器/开 LTO/内联展开后,保证立刻复活成死循环。
- **权威定性**：这属于**数据竞争(data race)** ——两个线程无同步地访问同一非原子变量、且至少一个是写。C/C++ 标准规定数据竞争是**未定义行为(UB)**，编译器**没有义务**让你"看到"另一个线程的写。死循环只是 UB 诸多合法后果里最常见的一种。

> **结论**:会发生、能复现、且是标准明确允许的 UB 后果——**不能靠"我测过能跑"来赌**。正确写法是让编译器知道"这个变量会被异步改":用 `std::atomic<bool>`(见 [memory-order.md](/concepts/memory-ordering/memory-order.md))，退一步用 `volatile`(仅够治这个寄存器缓存问题、但不解决多核可见性,见第四节)。

## 三、为什么单线程永远无感、多线程才出错

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<ok>> #C8E6C9
  BorderColor<<ok>>     #388E3C
  BackgroundColor<<bad>> #FFCDD2
  BorderColor<<bad>>    #C62828
}
rectangle "单线程视角\n只有'我'在读写这些变量\n换序/缓存后,我读到的\n始终是自洽的 → **无感**" <<ok>> as A
rectangle "多线程视角\n'我'的写顺序被换,\n**别的线程**按被换的顺序看到\n或:'我'缓存了旧值,看不到别人的写\n→ **逻辑崩坏**" <<bad>> as B
A -right-> B : 同一个优化
note bottom of A : as-if 保证的就是这个
note bottom of B : as-if 管不到这个\n(它不知道有'别的线程')
@enduml
```

- **单线程**:只有你自己读写这些变量，编译器换序/缓存后你读到的值始终自洽(它就是这么保证 as-if 的)——**你永远发现不了它动过手脚**。
- **多线程**:另一个线程按**被改过的**顺序观察你的写(或者你缓存了旧值看不到别人的写)，**跨线程的约定就被破坏了**——`flag` 死循环、"填好数据再抬标志"变成"标志先亮数据没到"。

> 这和 [store-buffer.md](/concepts/memory-ordering/store-buffer.md) 讲的硬件重排**现象相似、成因完全不同**:硬件重排是运行期 CPU/缓冲区造成的，编译器重排是**编译期**就把机器码顺序写死了。所以两者要**分别**用不同手段拦(见第五节)。

## 四、`volatile`：能治什么，不能治什么(最大误区)

很多人以为 `volatile` 是并发利器——**大错**。先说清它到底做什么。

`volatile` 对编译器的约束**只有两条**：

1. **不缓存进寄存器**:每次访问 `volatile` 变量都**真的生成一条 load/store 访存指令**(治得了第二节的 `while(!flag)` 死循环)。
2. **不删除、不合并 `volatile` 访问**，且**多个 `volatile` 访问之间不互相重排**(保持它们的相对顺序)。

#### 精确一点:`volatile` 是"每次真发访存指令",不是"每次读内存"

这是最容易说错的一点。`volatile` 是**编译器层**的概念，它约束的是编译器**发不发访存指令**，管不到这条指令发出去之后数据从哪来:

- `volatile` 保证:**每次访问都真生成 `mov reg,[addr]` 这样的 load/store**(不许用寄存器里的旧值、不许省略)。
- 但这条指令执行时,**数据从 L1 命中就从 L1 拿、miss 才逐级到内存**——这是 **CPU 缓存硬件**的事,`volatile` 完全管不着。**所以绝大多数 `volatile` 读其实停在 cache 命中,根本到不了内存。**

那为什么这样也能治死循环?**因为只要重新发 load,就重新进入了缓存一致性(MESI)的管辖范围**:线程B 改 `flag` → MESI 把线程A 缓存的那条 line 置 Invalid → `volatile` 强制发 load → miss → 取到最新值。**`volatile` 补上的是"读寄存器→读缓存"这一环(把视线从寄存器拉回缓存),"缓存→拿到最新值"那一环是 MESI 自动保证的**(见第三节)——**两环接上就读到新值,不需要真走到内存**。

三个"访问层次"对比,看清 `volatile` 到底停在哪:

| 概念 | 保证什么 | 数据到哪 |
|------|---------|---------|
| 普通变量(可优化) | 编译器可缓存进**寄存器**、甚至根本不访存 | 可能哪都不到(命中寄存器)|
| **`volatile`** | 每次真发**访存指令**、绕开寄存器 | **到 cache**(命中就停在 cache);miss 才到内存 |
| 真想绕过 cache 直达内存 | 需特殊手段:**uncacheable 内存**(如 MMIO 映射)、`clflush`/non-temporal 指令 | 内存 |

> 所以 `volatile` 最初正是为 **MMIO(设备寄存器)** 设计的——那块地址被标成 uncacheable、访问天然不走 cache、每次必须真访问硬件。但对**普通内存变量**,`volatile` 的访存照样走 cache,不强制到内存。**记住:`volatile` = 每次真发访存指令(bypass 寄存器),≠ 每次读内存(bypass cache)。**

`volatile` **做不到**的(致命):

- ❌ **不保证原子性**:`volatile long x; x++` 仍是"读-改-写"三步，多线程照样丢更新。
- ❌ **不是内存屏障**:它**只拦编译器**这一层，**完全管不住 CPU 乱序和 store buffer**(第3层硬件重排)。`volatile` 写在别的核眼里照样可能延迟可见、乱序可见。
- ❌ **volatile 访问和普通访问之间可以重排**:它只保证 volatile 之间有序，普通变量能穿插进来。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<y>> #C8E6C9
  BorderColor<<y>>     #388E3C
  BackgroundColor<<n>> #FFCDD2
  BorderColor<<n>>     #C62828
}
rectangle "volatile 能做\n· 每次真发访存指令(不进寄存器)\n  (到 cache 即可,不强制到内存)\n· 不删/不合并 volatile 访问\n· volatile 之间保持相对顺序" <<y>> as Y
rectangle "volatile 做不到\n· 原子性(x++ 仍丢更新)\n· 内存屏障(管不住 CPU/store buffer)\n· 拦 volatile↔普通变量的重排" <<n>> as N
note bottom of Y : 只是一个'弱编译器屏障'
note bottom of N : 多线程同步靠它 = 埋雷
@enduml
```

> **结论**:`volatile` 是**为内存映射 I/O(MMIO)、信号处理、`setjmp` 场景设计的**——那些"这块内存会被硬件/异步改，别给我缓存/优化掉"的需求。它**不是并发同步工具**。多线程共享数据的正确工具是 **`std::atomic`**(它同时压住编译器重排 + 硬件重排,见 [memory-order.md](/concepts/memory-ordering/memory-order.md))。Java/C# 的 `volatile` 语义完全不同(带内存屏障)，别把 C++ 的 `volatile` 当那个用——这是跨语言最大的坑之一。

## 五、怎么正确拦编译器重排

### 5.1 编译器屏障(compiler barrier)

最轻量的手段，告诉编译器"别跨这条线重排内存访问、别把内存值缓存在寄存器里":

```c
asm volatile("" ::: "memory");   // GCC/Clang:空指令 + "memory" clobber
// C++11:
std::atomic_signal_fence(std::memory_order_seq_cst);  // 标准写法,同义
```

关键性质:

- **不生成任何机器指令**(那个 `asm` 是空的)，**运行期零开销**。
- 它只是给编译器的一道"栅栏":栅栏前的内存写必须在栅栏前完成、栅栏后的读必须重新从内存读。
- **它只拦第1层(编译器)**——**挡不住 CPU 乱序和 store buffer**(第3层)!这是最常见的误用。

### 5.2 编译器屏障 ≠ 内存屏障(核心区分)

| | 编译器屏障 | 内存屏障(硬件) |
|---|---|---|
| 写法 | `asm volatile("":::"memory")` / `atomic_signal_fence` | `mfence`/`lock`/`dmb` / `atomic_thread_fence` |
| 拦哪层 | 第1层:编译器重排 | 第3层:CPU/store buffer 硬件重排 |
| 有无机器指令 | **无**,纯编译期约束 | **有**,真指令 |
| 运行期开销 | 零 | 有(排空 store buffer 等) |
| 单独用够吗(多线程) | **不够**——硬件照样乱序 | **也不够**——编译器还能在它周围乱序 |

> **最常见的并发 bug 之一**:加了 `mfence` 却被编译器把读提到写前(只拦了硬件没拦编译器)，或加了编译器屏障却被 store buffer 颠倒(只拦了编译器没拦硬件)。**正确的并发原语要两层一起下**——这正是 `std::atomic`(默认 `seq_cst`)替你做的:它**同时**发编译器屏障和内存屏障。所以并发同步**别自己拼屏障，用 `std::atomic`**(见 [memory-order.md](/concepts/memory-ordering/memory-order.md))。

### 5.3 用 std::atomic(推荐)

```c
std::atomic<bool> flag{false};
while (!flag.load(std::memory_order_acquire)) { }  // 编译器+硬件两层都被正确约束
```

`std::atomic` 的每个操作:①编译器不会缓存/乱序它 ②按你选的 `memory_order` 发相应硬件屏障。**这才是多线程共享变量的正确工具**,`volatile` 不是。

## 六、怎么"看见"编译器重排

编译器重排看反汇编最直接(见 [../elf/objdump.md](/concepts/elf/objdump.md)):

```bash
# 对比 -O0 和 -O2 生成的汇编,直接看到优化器动了哪些顺序、缓存了哪些变量
g++ -O0 -S test.cpp -o test_O0.s
g++ -O2 -S test.cpp -o test_O2.s
diff test_O0.s test_O2.s
# 或 Compiler Explorer(godbolt.org)在线对比,最直观
objdump -d -S ./app   # 反汇编 + 源码混排,看某个函数的指令顺序(见 objdump.md)
```

典型现象:`-O0` 里 `while(!flag)` 每轮都有一条 load 指令;`-O2` 里 load 被提到循环外、循环体变成纯 `jmp`(死循环)——这就是寄存器缓存优化的铁证。

### 6.1 一份完整可运行的例子:观察 data-race UB 的两种表现

> 📁 这一节的代码已做成**可直接编译运行的子项目**(跨 Linux/macOS):[demos/compiler-reordering/](/demos/compiler-reordering) —— `make run` 一键对比正确的原子版和错误的普通版,`make asm` 看反汇编坐实编译器动了手脚。

下面这份 `reorder_demo.cpp` 是自包含、可直接编译运行的。它开一个"等待线程"自旋在 `while(!flag)`,主线程睡 1 秒后把 `flag` 置真。用宏 `USE_ATOMIC` 切换"普通变量版(错误)"和"原子变量版(正确)":

```cpp
// reorder_demo.cpp
// 演示普通共享变量的 data-race UB,以及 std::atomic 的正确修复。
//   普通变量版:   g++ -O2 -pthread reorder_demo.cpp -o demo && ./demo
//                 → UB!等待线程根本没在等 flag(见下方"两种表现")
//   原子变量版:   g++ -O2 -DUSE_ATOMIC -pthread reorder_demo.cpp -o demo && ./demo
//                 → 正确:main 先 setting,waiter 才 saw,顺序永远对
#include <atomic>
#include <chrono>
#include <cstdio>
#include <thread>
#ifdef USE_ATOMIC
std::atomic<bool> flag{false};   // 正确:编译器不缓存进寄存器 + 带 acquire/release 语义
inline bool read_flag()  { return flag.load(std::memory_order_acquire); }
inline void write_flag() { flag.store(true, std::memory_order_release); }
#else
bool flag = false;               // 错误:普通变量,-O2 下 data race = UB
inline bool read_flag()  { return flag; }
inline void write_flag() { flag = true; }
#endif
int main() {
    std::thread waiter([] {
        std::puts("waiter: spinning on flag...");
        while (!read_flag()) {
            // 空循环体:编译器认定这里没人改 flag
        }
        std::puts("waiter: saw flag=true, exiting");
    });
    std::this_thread::sleep_for(std::chrono::seconds(1));
    std::puts("main:   setting flag=true");
    write_flag();
    waiter.join();
    std::puts("main:   joined, done");
    return 0;
}
```

#### 普通变量版的"两种表现"——都是同一个 UB

data race 是**未定义行为(UB)**,编译器没义务让你看到另一个线程的写。不同编译器/版本会选**不同的合法后果**,你可能看到下面**任意一种**:

**表现 A —— 死循环卡住(经典寄存器缓存)**

编译器把 `flag` 的 load 提到循环外,循环体退化成 `jmp .`,等待线程永远看不到 true → 卡死,要 Ctrl-C。

**表现 B —— "秒退",且打印顺序颠倒(你截图里就是这个)**

```bash
waiter: spinning on flag...
waiter: saw flag=true, exiting   ← waiter 在 main 之前就"看到"了 true
main:   setting flag=true         ← main 其实还没设置!
main:   joined, done
```

> 注意 **waiter 的 "saw" 打在了 main 的 "setting" 之前**——这不是"程序正常退出",恰恰是 bug:等待线程**根本没在等 `flag`**。成因是 C++ 的**前进保证(forward progress)**:标准规定"无副作用的循环可被假定会终止",编译器于是**直接把整个自旋循环删掉**,让 waiter 不经等待径直往下走。删循环和死循环是同一 data-race UB 的两副面孔,取决于你的编译器怎么选。

用时序图看清"编译期删循环"如何让运行时的打印顺序与源码意图颠倒——**左边是源码以为的顺序,右边是删循环后实际发生的**:

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  LifeLineBorderColor        #90A4AE
}
participant "waiter 线程" as W
participant "flag\n(内存)" as F
participant "main 线程" as M
note over W, M : 编译器已在**编译期**把 waiter 的\n`while(!read_flag()){}` 整个删掉(前进保证)\n→ 运行时 waiter 根本不检查 flag
W -> W : ① puts("spinning...")
note right of W : (循环已被删,\n**不产生任何 flag 读取**)
W -> W : ② 径直 puts("saw flag=true, exiting")
note right of W #FFCDD2 : waiter 此刻就退了,\nflag 其实还是 false ❌
activate M
M -> M : (还在 sleep 1 秒...)
M -> F : ③ 1 秒后才 write_flag() → flag=true
M -> M : ④ puts("setting flag=true")
deactivate M
note over W, M #FFCDD2 : 实际打印顺序:\nspinning → **saw(②)** → **setting(③④)**\n"看到"打在"设置"之前 = 铁证 bug
@enduml
```

对照正确程序应有的顺序(原子变量版):`spinning → setting → saw` —— main 先设置、waiter 才看到。**删循环把 ② 提到了 ③ 之前,顺序颠倒正是"waiter 压根没等"的外在证据。**

**判定是否为 bug 的铁律**:正确程序里 `main: setting` 必定在 `waiter: saw` **之前**。只要顺序反了、或根本卡死,就是 data race。**换成原子变量版,顺序永远正确**——这才是"程序真的在同步"。

#### 想稳定复现"表现 A 死循环"

如果你的编译器选了"删循环"(表现 B),想稳定看到卡死,给循环体加一句**有可观测副作用**的语句,让编译器不敢删循环、但仍会把 `flag` 缓存进寄存器:

```cpp
long spins = 0;
while (!read_flag()) {
    ++spins;                 // 有副作用 → 编译器不能删循环
    asm volatile("" :: "r"(spins) : );  // 阻止把 ++spins 也优化掉
}
std::printf("waiter: saw flag=true after %ld spins\n", spins);
```

此时 `-O2` 下编译器保留循环,但仍把 `flag` 读进寄存器只读一次 → 稳定死循环(`spins` 一直加、永不退出)。

#### 用反汇编坐实"编译器动了手脚"

```bash
# 普通变量版:看 waiter 的自旋循环——load 被提出循环 / 循环被删
g++ -O0 -pthread -S reorder_demo.cpp -o plain_O0.s
g++ -O2 -pthread -S reorder_demo.cpp -o plain_O2.s
diff plain_O0.s plain_O2.s
#   -O0:循环体每轮都有 movzbl flag(%rip),%eax(每轮真读内存)
#   -O2:循环体里没有 flag 的 load —— 要么 jmp . 死跳(表现A),要么循环整段消失(表现B)
# 原子变量版:即使 -O2,循环体里仍每轮真发 load
g++ -O2 -DUSE_ATOMIC -pthread -S reorder_demo.cpp -o atomic_O2.s
#   循环体里稳定出现 movzbl flag(%rip),%eax —— 每轮从内存/缓存重读,MESI 那条链接得上
```

> **对照结论**:同一份自旋循环,`flag` 是普通变量时 `-O2` 把 load 提出循环(死循环)或干脆删掉循环(秒退且顺序颠倒)——**两种都是 data-race UB,不是"能跑"**;换成 `std::atomic<bool>` 后 `-O2` 仍老实每轮发 load、打印顺序永远正确。这就是为什么并发共享变量必须用 `std::atomic` 而非裸变量(见第四、五节)。想只修编译器这一层做实验,也可把 `read_flag` 里的读改成先 `asm volatile("":::"memory");` 再读 `flag`(编译器屏障,零开销),死循环/删循环同样消失——但那只拦住第1层,多核可见性仍要靠 `std::atomic`。

> 呼应本仓库的教学点:`make release`(`-O2`)会让 `main.cpp` 里 `sum += i` 的热循环被整个优化掉(死代码消除)——那也是编译器"as-if"授权下的合法删除(见 [CLAUDE.md](/CLAUDE.md) 的已知简化说明、[compile-link-load.md](/concepts/elf/compile-link-load.md))。

## 七、和本仓库其他文档的关系

- **总纲**:[reordering-overview.md](/concepts/memory-ordering/reordering-overview.md)——三层重排框架，本篇是其"第1层"的展开(与第2层 [cpu-out-of-order.md](/concepts/microarch/cpu-out-of-order.md)、第3层 [memory-reordering.md](/concepts/memory-ordering/memory-reordering.md) 并列,三层各有一篇)。
- **正确工具**:[memory-order.md](/concepts/memory-ordering/memory-order.md)(`std::atomic` + `memory_order` 如何同时压住编译器和硬件两层)、[atomic.md](/concepts/cache/atomic.md)。
- **硬件层对照**:[store-buffer.md](/concepts/memory-ordering/store-buffer.md) / [memory-reordering.md](/concepts/memory-ordering/memory-reordering.md)——现象像、成因和拦法都不同(编译期 vs 运行期)。
- **观测工具**:[../elf/objdump.md](/concepts/elf/objdump.md)(反汇编看重排)、[compile-link-load.md](/concepts/elf/compile-link-load.md)(编译优化阶段)。

## 八、一句话总结

> **编译器重排是"重排"的第一层、发生在编译期:根据 as-if 规则,编译器只需保证单线程可观测结果不变,就能自由换序/寄存器缓存/删除/合并你的读写——它假设代码是单线程的,意识不到别的线程在看,于是多线程下闯祸(经典是 `while(!flag)` 把 flag 缓存进寄存器成死循环)。它和硬件重排完全独立,单核也会发生。`volatile` 只是个'弱编译器屏障'(不缓存/不删/volatile 间有序),但既不保证原子性、也不是内存屏障,绝不能当并发同步工具用。正确拦法:编译器屏障(`asm volatile("":::"memory")`,零开销但只拦这一层)——而多线程同步要编译器+硬件两层一起下,直接用 `std::atomic`,别自己拼。**

