一、内存序是什么?先别急着背定义

内存序(memory order)这玩意儿,说白了就是告诉编译器和CPU:你对内存操作的顺序,能容忍多大程度的乱序

我刚开始学C++11的内存模型时,觉得这东西离我挺远的。直到有一次,我在一个多生产者-单消费者的日志系统里,遇到了诡异的数据丢失——明明代码逻辑看起来完全正确,但跑在ARM芯片上就是偶尔丢数据。嗯,从那以后,我再也不敢轻视内存序了。

你想想看,现代CPU和编译器为了性能,会偷偷重排你的指令。单线程下没问题,但多线程下,这种「好心」就会酿成大祸。C++11引入的6种内存序,就是用来约束这种「好心」的。

二、六种内存序全景图

先给你一张总览图,心里有个谱:

C++ 六种内存序全景 约束由弱 → 强(性能由高 → 低) relaxed 无任何约束 consume 数据依赖有序 acquire 读操作屏障 release 写操作屏障 acq_rel 读+写屏障 seq_cst 全局一致序 典型使用场景 relaxed 计数器、统计 不依赖顺序 consume 指针解引用 (极少用) acquire 读锁、加载 标志位 release 写锁、存储 标志位 acq_rel 读-改-写 操作 seq_cst 默认值 最安全最慢 箭头方向:约束越来越强,性能越来越低 💡 日常开发中,90% 的场景用 acquire/release 或 seq_cst 就够了

三、逐个拆解,一个都别想跑

1. memory_order_relaxed —— 最自由的野马

relaxed 是最弱的内存序。它只保证原子操作本身的原子性,不保证任何顺序约束。说白了,就是「我只管这次读写是原子的,至于它和别的操作谁先谁后,我不管」。

我在项目中用 relaxed 最多的地方就是统计计数器。比如记录某个函数被调用了多少次,或者统计内存分配的总字节数——这些场景只关心最终值,不关心顺序。

适用场景:计数器、统计指标、不需要同步数据的场景。
// 统计计数器示例
std::atomic<uint64_t> total_allocated{0};

void* my_alloc(size_t size) {
    // relaxed 足够,我们只关心总数,不关心顺序
    total_allocated.fetch_add(size, std::memory_order_relaxed);
    return malloc(size);
}
⚠️ 避坑:我曾经在一个生产者-消费者队列里用了 relaxed,结果消费者读到的数据全是错的。因为 relaxed 不保证「我先写数据再写标志位」这个顺序——消费者可能先看到标志位变了,但数据还没写进去。血的教训。

2. memory_order_consume —— 被遗忘的角落

consume 是个很有意思的存在。它只保证数据依赖的有序性。什么意思?就是如果 B 依赖于 A 的值,那么 A 的写入一定在 B 读取之前完成。

但说实话,我几乎不用 consume。为什么?因为大多数编译器(包括 GCC 和 Clang)都把 consume 直接升级成了 acquire。C++17 甚至明确建议「不要用 consume,用 acquire 代替」。

💡 个人建议:别在 consume 上浪费时间。你写 acquire 编译器会帮你优化,写 consume 反而可能因为编译器升级成 acquire 而失去性能优势。得不偿失。

3. memory_order_acquire —— 读操作的守护者

acquire 是一个读操作屏障。它保证:在这个 load 操作之后的所有读和写,都不会被重排到这个 load 之前。

你想想看,这有什么用?最常见的场景就是检查标志位

std::atomic<bool> ready{false};
std::string data;

// 线程1:生产者
void producer() {
    data = "Hello, World!";          // 1. 写数据
    ready.store(true, std::memory_order_release);  // 2. 设置标志
}

// 线程2:消费者
void consumer() {
    while (!ready.load(std::memory_order_acquire)) {  // 3. 检查标志
        // 等待
    }
    std::cout << data << std::endl;  // 4. 读数据 —— 保证能看到线程1写入的值
}

这里 acquire 保证了:第4步的读数据,一定不会重排到第3步之前。所以一旦我们看到 ready 为 true,就一定能读到正确的 data。

4. memory_order_release —— 写操作的守护者

release 和 acquire 是天生一对。它是写操作屏障,保证:在这个 store 操作之前的所有读写,都不会被重排到这个 store 之后。

还是上面那个例子,release 保证了:第1步写 data 一定在第2步写 ready 之前完成。这样消费者看到 ready 为 true 时,data 一定已经写好了。

核心思想:release 和 acquire 必须配对使用。一个 release 写入,一个 acquire 读取,就能在它们之间建立「happens-before」关系。

5. memory_order_acq_rel —— 读写通吃

acq_rel 就是 acquire + release 的合体。它同时具备读屏障和写屏障的效果。通常用在读-改-写(RMW)操作上,比如 fetch_addcompare_exchange 等。

std::atomic<int> counter{0};

// 一个典型的 RMW 操作
int expected = counter.load(std::memory_order_relaxed);
while (!counter.compare_exchange_weak(
    expected, expected + 1,
    std::memory_order_acq_rel,  // 成功时用 acq_rel
    std::memory_order_relaxed   // 失败时用 relaxed
)) {
    // 重试
}

为什么 RMW 操作需要 acq_rel?因为你要同时保证:读取旧值时能看到别人的写入(acquire),写入新值时别人也能看到你的写入(release)。

6. memory_order_seq_cst —— 最安全的默认选择

seq_cst最强的内存序,也是 std::atomic默认值。它保证所有线程看到的操作顺序完全一致——就像所有操作都在一个全局时钟下顺序执行一样。

但代价呢?性能开销最大。在 x86 上还好,因为 x86 本身的内存模型就比较强。但在 ARM 或 PowerPC 上,seq_cst 会插入昂贵的内存屏障指令。

💡 我的习惯:刚开始写多线程代码时,先用 seq_cst 保证正确性。等代码跑通了,再用性能分析工具看看瓶颈在哪,再考虑降级到 acquire/release。不要一开始就追求极致性能,正确性永远是第一位的。

四、一张表总结所有内存序

内存序 约束强度 读屏障 写屏障 全局一致 典型场景
relaxed 最弱 计数器、统计
consume 数据依赖 指针解引用(不推荐)
acquire 读标志位、加载
release 写标志位、存储
acq_rel RMW 操作
seq_cst 最强 默认选择、复杂同步

五、避坑指南:我踩过的那些坑

坑1:以为 relaxed 能保证顺序

我曾经写过一个无锁队列,用 relaxed 来操作 head 和 tail 指针。结果在 ARM 上跑,消费者读到了半写的数据。后来加上 acquire/release 才修好。记住:relaxed 只保证原子性,不保证顺序。

坑2:acquire 和 release 不配对

有个同事写代码,生产者用 release 写标志,消费者用 relaxed 读标志。结果消费者永远看不到标志变化——因为 relaxed 读不会建立任何 happens-before 关系。acquire 和 release 必须成对出现。

坑3:过度使用 seq_cst

我见过一个项目,所有原子操作都用 seq_cst。性能测试发现瓶颈全在内存屏障上。后来把大部分改成 acquire/release,性能提升了 3 倍。seq_cst 虽好,但别滥用。

六、总结:怎么选?

给你一个简单的决策树:

  • 只需要原子性,不关心顺序? → relaxed(计数器、统计)
  • 需要同步数据,一个写一个读? → release + acquire 配对
  • 需要读-改-写操作? → acq_rel
  • 不确定选哪个? → seq_cst(默认值,最安全)
  • 想用 consume? → 别想了,用 acquire 代替

最后说一句:内存序这东西,光看文档是学不会的。我建议你写几个小 demo,在不同的架构上跑一跑,亲眼看看不同内存序的行为差异。实践出真知,这话一点不假。


公众号:蓝海资料掘金营,微信deep3321