Appearance
编译器重排 data-race demo
把 concepts/memory-ordering/compiler-reordering.md 第六节讲的问题做成可亲手复现的例子:一个普通共享变量 flag + 自旋等待循环,在 -O2 下如何触发 data-race UB,以及换成 std::atomic 后如何变正确。
跨平台:Linux(g++) 与 macOS(clang++/c++, 含 Apple Silicon) 均已验证可跑。
一分钟上手
bash
make # 编译四个二进制
make run # 一键对比:先跑正确的原子版,再跑错误的普通版(带 3 秒超时保护)make run 你会看到两段输出的打印顺序不同——这就是全部重点:
bash
[正确] 原子版: spinning → main setting → waiter saw ← 顺序永远对
[错误] 普通版: spinning → waiter saw → main setting ← "saw" 跑到 "setting" 前面 = bug
(或:卡死 3 秒被超时杀掉 = 另一种表现)两个源文件、四个二进制
| 源文件 | 宏 | 二进制 | 预期 |
|---|---|---|---|
reorder_demo.cpp | (无) | demo_plain | 错误:表现 A 死循环 或 表现 B 秒退且顺序颠倒 |
reorder_demo.cpp | -DUSE_ATOMIC | demo_atomic | 正确:setting 永远在 saw 之前 |
reorder_demo_spins.cpp | (无) | spins_plain | 错误:稳定死循环(循环体有副作用,编译器不敢删,但仍缓存 flag) |
reorder_demo_spins.cpp | -DUSE_ATOMIC | spins_atomic | 正确:正常退出,打印自旋了多少次 |
普通变量版的两种表现(都是同一个 data-race UB,取决于你的编译器/版本怎么选):
- 表现 A —— 死循环卡住:编译器把
flag的 load 提出循环、循环体退化成原地跳 → 等待线程永远看不到true,要 Ctrl-C(make run里 3 秒超时会替你杀掉)。 - 表现 B —— 秒退且顺序颠倒:编译器认定"无副作用的自旋循环必然终止"(C++ 前进保证),直接把整个循环删掉 → 等待线程根本没等,
saw打在main setting之前。
想稳定看到表现 A(死循环),用 spins 版:
bash
make run-spins-atomic # 正确:正常退出
make run-spins-plain # 错误:稳定死循环(spins 疯涨),3 秒后超时终止用反汇编坐实"编译器动了手脚"
bash
make asm # 生成 plain_O0.s / plain_O2.s / atomic_O2.s
diff plain_O0.s plain_O2.s # 看普通版自旋循环:-O0 每轮有 flag 的 load,-O2 里没了
grep -n 'flag' atomic_O2.s # 原子版 -O2 仍每轮真发 load-O0普通版:循环体每轮都有一条flag的 load(x86:movzbl flag(%rip),...;arm64:ldrb)。-O2普通版:循环体里flag的 load 消失——被提出循环(表现 A)或整段删掉(表现 B)。-O2原子版:循环体里仍每轮真发 load——所以能读到别的线程的写(MESI 那条链接得上)。
手动编译(不用 make)
bash
# Linux
g++ -O2 -std=c++17 -pthread reorder_demo.cpp -o demo_plain # 错误版
g++ -O2 -std=c++17 -pthread -DUSE_ATOMIC reorder_demo.cpp -o demo_atomic # 正确版
# macOS(clang 自带,c++ 即 clang++)
c++ -O2 -std=c++17 -pthread reorder_demo.cpp -o demo_plain
c++ -O2 -std=c++17 -pthread -DUSE_ATOMIC reorder_demo.cpp -o demo_atomic说明与坑
- 必须
-O2:UB 只在优化下暴露,-O0下普通版通常"碰巧能跑"——但那是运气不是正确(文档反复强调的点)。 - 表现 A/B 因编译器而异:同一份代码,GCC / Clang / 不同版本可能给不同结果,甚至换
-O2/-O3/开 LTO 就变。这正是 UB 的定义——没有"标准答案",所以不能靠"我测过能跑"来赌。 - 超时保护:
make run用timeout(Linux 自带)或gtimeout(macOSbrew install coreutils)限 3 秒;都没有时退化成"后台跑 + 到点 kill"的可移植方案,所以 macOS 裸机也能安全跑,不会真卡死终端。 - 结论:并发共享变量用
std::atomic(同时压住编译器重排 + 硬件可见性),别用裸变量 + 手动屏障。原理见 compiler-reordering.md、memory-order.md。