一周崩一次的诡异读数,加日志就消失

一个后台线程准备好 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

不背表格,记三步推导:

  1. sequenced-before:单线程内,程序序里靠前的语句 sequenced-before 靠后的。
  2. synchronizes-with:跨线程同步的唯一来源——一个 acquire 读,读到了同一个原子对象上某次 release 写(或其 release sequence 的延续)写下的值。
  3. 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 证明"这个程序没有数据竞争"的数学作业。