一周崩一次的诡异读数,加日志就消失
一个后台线程准备好 Packet,置位 flag,另一个线程轮询 flag 然后读 payload。线上每隔几天出现一次"flag 已置位、payload 却是旧的":
#include <atomic>
#include <cstdio>
#include <thread>
struct Packet {
unsigned long long seq = 0;
char payload[256] = {};
};
Packet packet; // 非原子,也没有锁保护
std::atomic<bool> ready{false};
void producer() {
packet.seq = 42;
std::snprintf(packet.payload, sizeof packet.payload, "hello");
ready.store(true, std::memory_order_relaxed); // 症状源头:relaxed 发布
}
void consumer() {
while (!ready.load(std::memory_order_relaxed)) {
std::this_thread::yield();
}
// 偶尔读到旧的 seq 或半旧的 payload
std::printf("seq=%llu payload=%s\n", packet.seq, packet.payload);
}
flag 本身是 atomic,没有竞争;竞争在 packet 上:一个线程写、另一个线程读、两者之间没有任何同步关系。按标准这是 data race,是 UB。UB 的意思是"没有任何行为保证"——包括"看起来正常地跑很久"。
加一行 printf 复现就消失,也不算证据:printf 改变了时序和编译器的优化决策,只是把窗口挤小了。“测试没复现"和"没有竞争"是两件事。
心智模型:先画边,再选 order
不背表格,记三步推导:
- sequenced-before:单线程内,程序序里靠前的语句 sequenced-before 靠后的。
- synchronizes-with:跨线程同步的唯一来源——一个 acquire 读,读到了同一个原子对象上某次 release 写(或其 release sequence 的延续)写下的值。
- happens-before:上面两种关系的传递闭包。写 W 对读 R 可见的前提,是 W happens-before R。
所以 memory_order 不是性能旋钮,而是建立 synchronizes-with 边的工具。正确的推导顺序是:先列出哪些写必须对哪些读可见;每条可见性要求对应一条跨线程边;每条边用一对 release/acquire 实现;剩下的访问才允许 relaxed。
发布-订阅:acquire/release 的标准配对
修正版,完整可编译:
#include <atomic>
#include <cstdio>
#include <thread>
struct Packet {
unsigned long long seq = 0;
char payload[256] = {};
};
Packet packet; // 发布之后不再修改
std::atomic<bool> ready{false};
void producer() {
packet.seq = 42;
std::snprintf(packet.payload, sizeof packet.payload, "hello");
// release:本线程里 sequenced-before 这次 store 的所有写入,
// 都被"打包"在这个写操作上
ready.store(true, std::memory_order_release);
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) {
std::this_thread::yield();
}
// acquire 读到了 release 写入的值 → synchronizes-with 成立
// → producer 中先于 store 的一切 happens-before 下面这行 printf
std::printf("seq=%llu payload=%s\n", packet.seq, packet.payload);
}
int main() {
std::thread t(consumer);
producer();
t.join();
}
两个 relaxed 为什么"看似能跑”
把上面两个 order 换成 relaxed,在 x86 上大概率测不出问题。这不代表正确,代表运气:
- x86 是 TSO:硬件不呈现 store-store、load-load 的乱序,release/acquire 落到指令层基本就是普通 mov。真正先咬你的是编译器——relaxed 不约束它把非原子访问重排到原子操作的另一侧。
- 换到 ARM,硬件本身就会把这两类乱序呈现出来,失败率立刻上一个量级。
在 x86 上"能跑"证明的是 x86 的内存模型,不是程序的正确性。标准和编译器都保留了让你翻车的自由,而且它们真的会用。
release sequence:acquire 为什么能看到"发布之前的全部"
synchronizes-with 的准确条件不是"acquire 读到了 release 写的值",而是"acquire 读到了以某个 release store 为头的 release sequence 写下的最终值"。序列的延续规则(C++20 起收窄):头部的 release store,加上一路上"读到前序值"的原子 RMW 操作。直觉版:令牌只要经手的全是原子 RMW,最初的 release 就一直有效。
std::atomic<int> token{0};
Data data; // 非原子
void writer() {
data = build();
token.store(1, std::memory_order_release); // 发布
}
void relay() {
int expected = 1; // 假设运行到这里时 token 已经是 1
// relaxed CAS:RMW 会延续 release sequence,不掐断同步
token.compare_exchange_strong(expected, 2, std::memory_order_relaxed);
}
void reader() {
// 读到 2(不是 1)依然与 writer 的 release 同步
if (token.load(std::memory_order_acquire) == 2) {
use(data); // 可见性有保证
}
}
这就是"release 发布、中间几手 fetch_add 传递、acquire 收尾"这类令牌模式成立的原因。
relaxed 的合法用途:不携带同步义务的量
判据一句话:这个原子量是否承担"让别的数据可见"的职责。承担,就必须 acquire/release;只代表它自己,relaxed 足够。
struct Stats {
std::atomic<unsigned long long> hits{0};
std::atomic<unsigned long long> last_seq{0};
void record(unsigned long long seq) {
hits.fetch_add(1, std::memory_order_relaxed); // 只数个数
last_seq.store(seq, std::memory_order_relaxed); // 只看个大概,不发布任何数据
}
};
经典错误就是开头那个:用 relaxed 的 flag 发布非原子数据——flag 没有携带任何同步义务,读到的 payload 没有可见性保证。
引用计数:relaxed 与 acq_rel 各就各位
shared_ptr 控制块内部就是这套组合,值得记下来:
#include <atomic>
#include <cstdint>
class Widget {
std::atomic<uint32_t> refs_{1};
public:
void add_ref() noexcept {
// 增引用只动计数:指针是沿着已有引用传过来的,
// 传递路径(参数、channel、锁)已经建立过 happens-before
refs_.fetch_add(1, std::memory_order_relaxed);
}
void release_ref() noexcept {
// release:本线程对对象的写,先于"计数减到零"被外界观察到;
// acquire:抢到删除权的线程,必须看到其他线程各自 release
// 之前的全部写入。acq_rel 一次满足两个方向。
if (refs_.fetch_sub(1, std::memory_order_acq_rel) == 1) {
delete this;
}
}
};
注意不对称的合理性在别处:加引用不需要同步,因为"能拿到指针"这件事本身已经隐含同步;减引用到零的那次是终点,两个方向的保证都要。
seq_cst:当不变式横跨多个变量
绝大多数代码只需要 acquire/release。什么时候真的要 seq_cst?当正确性依赖"两个线程对多个变量的修改顺序达成一致"时。Dekker 式的互斥进入协议是标本:
std::atomic<bool> want0{false}, want1{false};
// 线程 0:
want0.store(true, std::memory_order_seq_cst);
if (!want1.load(std::memory_order_seq_cst))
enter_critical_section();
// 线程 1:
want1.store(true, std::memory_order_seq_cst);
if (!want0.load(std::memory_order_seq_cst))
enter_critical_section();
如果两个线程都读到 false,就都进了临界区,不变式破灭。acquire/release 允许这种结果:每个线程的 load 都可能排在"自己的 store 被别人观察到"之前——store-load 乱序连 x86 都允许。seq_cst 提供所有 seq_cst 操作的一个全序,两个 load 在这个全序里都排在两个 store 之后,至少一个线程会看到 true。
代价真实存在:x86 上 acquire/release 基本就是普通 mov,seq_cst store 需要 mfence 或 locked 指令;ARM 上 relaxed 是普通 ldr/str,acquire/release 映射为 ldar/stlr 已经有真实成本,seq_cst 更重。所以顺序是"默认 acquire/release,证明需要全序才升 seq_cst",不是反过来图省事。
consume 与 fence:两件别碰的东西
memory_order_consume:意图是利用数据依赖做更便宜的同步,但标准语义从没有编译器真正实现,主流编译器一律按 acquire 处理。写 consume 等于写 acquire 还埋了可移植性坑——别用。
std::atomic_thread_fence:和写在操作上的 order 参数是两套表达力,fence 的配对规则(尤其 seq_cst fence 与 acquire fence 的差别)更容易出错。除非在维护无锁数据结构的内部,优先把 order 写在 load/store 上。
工具:让 UB 变成可报告的东西
# TSan 需要全程序插桩(链接的库也要),放 CI 里跑测试,别上生产
clang++ -fsanitize=thread -g -O1 demo.cpp -o demo && ./demo
# 典型开销:慢 5~15 倍、内存 5~10 倍
# 报告直接画出 happens-before 关系,正好对着本文的推导流程
TSan 报出来的竞争基本可信;没报不等于没有(它靠观察执行,会漏)。再加一条静态防线:
// 确认关键原子量没有退化成内部锁(锁是另一类正确但昂贵的东西)
static_assert(std::atomic<unsigned long long>::is_always_lock_free);
最后一条纪律:压测通过不是无竞争的证明。内存序正确性靠推导,测试和工具只负责抓你推导错的那部分。
常见坑点
把 volatile 当同步原语
volatile 只承诺"访问不被优化掉、volatile 之间不被编译器重排";它不建立跨线程 happens-before,不阻止 CPU 乱序,宽于机器字长的对象还可能读到撕裂值。它是给 MMIO 寄存器设计的,不是给线程的。
双重检查锁定的历史教训
C++11 之前,DCLP(double-checked locking)是"无法既线程安全又高效"的标本:obj = new Object 的分配、构造、发布之间没有顺序保证,另一个线程可能看到"指针已非空但对象未构造完"。C++11 的答案是函数局部 static:
Object& instance() {
static Object obj; // 标准保证首次初始化线程安全、恰好一次
return obj;
}
多数还在想手写 DCLP 的场合,要么这个,要么 std::call_once,就够了。
fence 和 order 混用
同一条同步边,一半用 fence 表达、一半用 order 参数表达,读代码的人要在脑子里做两次等价变换才确认语义一致。选一种表达方式,无特殊情况就用 order 参数。
总结
内存序问题的解法是固定的:先画 happens-before——谁写、谁读、哪条边缺了;然后用 release/acquire 把边补上;relaxed 只留给不携带同步义务的量;横跨多变量的全序不变式才请出 seq_cst。画不出边的代码,加多少压测都只是延后翻车时间。
内存序不是优化技巧,是你向编译器和 CPU 证明"这个程序没有数据竞争"的数学作业。