内存顺序详解:从 relaxed 到 seq_cst,你的代码到底有多“乱”?

说实话,内存顺序这个话题,我当年刚接触 C++11 并发库的时候,也绕了好一阵子。你想想看,明明代码里写的是 x = 1,然后 y = 2,结果另一个线程看到的顺序可能是 y = 2 先发生?这听起来像 bug,但其实是 CPU 和编译器为了性能搞的“小动作”。

今天我们就来把这三种内存顺序彻底讲透。我会结合我自己的踩坑经历,帮你理清它们到底该什么时候用。

为什么需要内存顺序?

先问一个问题:多线程环境下,一个线程对变量的修改,另一个线程什么时候能看到?

答案很残酷——不一定。CPU 会乱序执行,编译器会重排指令,缓存也可能没刷出去。所以 C++ 给了你一套“内存顺序”标签,让你告诉编译器和 CPU:这里,你必须给我一个确定的顺序

我个人习惯把内存顺序分成三个等级:

  • relaxed:最自由,几乎不保证顺序
  • acquire/release:成对使用,保证关键点的顺序
  • seq_cst:最严格,全局一致

下面我们一个一个来看。

memory_order_relaxed:最“随意”的保证

memory_order_relaxed 说白了就是:只保证原子操作本身是原子的,不保证任何顺序。也就是说,两个 relaxed 操作,在别的线程看来,顺序可能是乱的。

我在项目中遇到过这样一个场景:统计一个全局计数器,多个线程往里加,但不需要用这个计数器做同步。这时候 relaxed 就够用了。

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

void worker() {
    for (int i = 0; i < 1000; ++i) {
        counter.fetch_add(1, std::memory_order_relaxed);
    }
}

这段代码能保证最终结果是 1000 * 线程数,但如果你在另一个线程里读 counter,它可能不是最新的——因为 relaxed 不保证“可见性”。

注意:relaxed 不能用于需要同步的场景。比如用 relaxed 实现自旋锁,那肯定出问题。

我曾经犯过一个错:用 relaxed 去检查一个 flag,然后根据 flag 的值去读另一个变量。结果 flag 读到了 true,但那个变量还没被写入。嗯,这就是典型的“顺序没保证”导致的 bug。

memory_order_acquire / release:成对出现的“契约”

acquire 和 release 是成对使用的。它们解决的是这样一个问题:一个线程写入数据,另一个线程安全地读取

  • release:写入操作。保证在这个操作之前的所有写入,都不会被重排到这个操作之后。
  • acquire:读取操作。保证在这个操作之后的所有读取,都不会被重排到这个操作之前。

说白了,release 就像“我写完了,你来看吧”,acquire 就像“好,我现在来看”。

来看一个经典的生产者-消费者例子:

std::atomic<bool> ready{false};
int data = 0;

// 生产者线程
void producer() {
    data = 42;                                    // 普通写
    ready.store(true, std::memory_order_release); // release 写
}

// 消费者线程
void consumer() {
    while (!ready.load(std::memory_order_acquire)); // acquire 读
    // 此时 data 一定是 42
    std::cout << data << std::endl;
}

这里的关键是:ready.store(..., release) 保证了 data = 42 一定在它之前完成。而 ready.load(..., acquire) 保证了读 data 一定在它之后。所以消费者看到的 data 一定是 42。

我的建议:如果你需要传递数据,用 acquire/release 是最合适的。它比 seq_cst 性能好,又比 relaxed 安全。

我记得有一次优化一个消息队列,把 seq_cst 改成 acquire/release,吞吐量提升了将近 30%。当然,前提是你得确保逻辑正确。

memory_order_seq_cst:最严格的全局顺序

memory_order_seq_cst 是默认值,也是最严格的。它保证:所有线程看到的所有原子操作,顺序完全一致

你想想看,这意味着什么?意味着整个程序里,所有 seq_cst 操作,在任意一个线程看来,顺序都是一样的。就像有一个全局的“时钟”,每个操作都按这个时钟的 tick 来执行。

但代价呢?性能开销最大。因为 CPU 和编译器几乎不能做任何重排优化。

std::atomic<int> x{0}, y{0};
int r1, r2;

// 线程1
void thread1() {
    x.store(1, std::memory_order_seq_cst);
    r1 = y.load(std::memory_order_seq_cst);
}

// 线程2
void thread2() {
    y.store(1, std::memory_order_seq_cst);
    r2 = x.load(std::memory_order_seq_cst);
}

在这个例子里,如果两个线程同时跑,最终 r1r2 不可能同时为 0。因为 seq_cst 保证了全局顺序,总有一个 store 先发生。

注意:seq_cst 虽然最安全,但不要滥用。我见过有人把所有原子操作都写成 seq_cst,结果性能惨不忍睹。能用 acquire/release 就别用 seq_cst。

三种内存顺序对比

内存顺序 保证内容 性能 典型场景
relaxed 仅保证原子性 最高 计数器、统计
acquire/release 成对保证顺序 中等 数据传递、锁实现
seq_cst 全局一致顺序 最低 复杂同步、算法验证

知识体系图

下面这张图帮你理清这三种内存顺序的关系和适用场景:

C++ 内存顺序知识体系 memory_order_relaxed 仅保证原子性 acquire / release 成对保证顺序 seq_cst 全局一致顺序 性能对比(从高到低) relaxed(最高性能) acquire/release seq_cst(最低性能) 典型适用场景 计数器、统计 数据传递、锁 复杂同步

避坑指南

最后,分享几个我踩过的坑:

  • 不要默认用 seq_cst:很多人写 atomic<int> x; 然后直接 x.load(),默认就是 seq_cst。如果不需要全局顺序,改成 acquire/release 能省不少性能。
  • relaxed 不能用于同步:我曾经用 relaxed 实现了一个简单的 spinlock,结果在高并发下完全失效。因为 relaxed 不保证可见性。
  • acquire/release 必须成对:如果你只用了 release 写,但读的时候用了 relaxed,那 release 的保证就白费了。读端必须用 acquire。
  • 别在复杂算法里用 relaxed:除非你非常清楚自己在做什么,否则老老实实用 seq_cst 或 acquire/release。relaxed 的坑太多了。

好了,关于内存顺序就讲到这里。记住一句话:用最弱的顺序,满足最强的需求。这是我在并发编程里学到的最大教训。

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