⚡C++ 并发编程

原子变量与内存序

面试回答

常见问法

  • 原子变量已经线程安全了,为什么还需要内存序?
  • memory_order_seq_cstmemory_order_relaxed 到底差在哪?
  • acquire/release 是怎么保证“一个线程写了,另一个线程能看到”的?
  • 什么是 happens-before?它和可见性、重排是什么关系?
  • 工程里该怎么选内存序?是不是默认都用 seq_cst 就行?

回答

原子变量解决的是单次读写或读改写操作不可分割的问题,但它不自动解决跨线程看到的顺序问题。 也就是说,std::atomic 能保证一个变量的操作不会被“撕裂”,但线程 A 的多个写操作,线程 B 以什么顺序观察到,还要看内存序。

内存序本质上是在定义两件事:

  1. 编译器和 CPU 能否重排这些操作
  2. 一个线程的写入何时对另一个线程可见

所以面试里要先讲清一个核心点:

原子性 != 同步性 != 顺序性

  • memory_order_relaxed:只保证原子性,不建立跨线程同步关系。适合计数器、统计值这类“值不能乱,但顺序无所谓”的场景。
  • memory_order_acquire/release:用于在线程之间建立发布-订阅关系。一个线程 release 发布,另一个线程 acquire 读取到这个结果后,就能看到发布前的普通写入。
  • memory_order_seq_cst:最强约束。除了保证原子性和同步外,还给所有 seq_cst 原子操作提供一种全局一致顺序的观感,最容易推理,但代价通常更高。

一句话概括选择原则:

没有跨线程依赖,只求原子计数,用 relaxed; 需要线程间发布数据,用 release/acquire; 逻辑复杂、容易出错、优先正确性时,用 seq_cst

典型回答示例

#include <atomic>
#include <thread>
#include <iostream>

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

void publisher() {
    data = 42; // 普通写
    ready.store(true, std::memory_order_release); // 发布
}

void subscriber() {
    while (!ready.load(std::memory_order_acquire)) {
    }
    std::cout << data << '\n'; // 一定看到 42
}

这里 data 虽然不是原子变量,但仍然是安全的,原因不是“它本身线程安全”,而是:

  • publisherready.store(..., release) 之前的写入不能被重排到后面
  • subscriberready.load(..., acquire) 之后的读取不能被重排到前面
  • subscriber 读到 ready == true 时,形成同步关系,因此能看到 data = 42

这就是典型的 release-acquire 同步边

追问

1. 什么是 happens-before

它可以理解为“先行发生”关系。 如果 A happens-before B,那么 B 一定能看到 A 的结果,且 A 不会在语义上跑到 B 后面。

面试里可以这样答:

happens-before 是 C++ 内存模型里判断可见性和顺序性的核心概念。 同一个线程内的顺序、锁的解锁-加锁、原子的 release-acquire,都可以建立 happens-before。 没有 happens-before,两个线程对同一数据的访问就可能出现数据竞争,行为未定义。


2. relaxed 什么时候能用?

当你只需要这个原子变量本身是正确的,但不依赖它去同步其他数据时可以用。

典型场景:

  • 请求计数器
  • 性能统计
  • 命中次数
  • 限流中的简单票数递增

例如:

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

void worker() {
    counter.fetch_add(1, std::memory_order_relaxed);
}

这里大家只关心最后计数对不对,不关心“某次加一”与其他普通变量写入之间的顺序。


3. acquire/releaseseq_cst 差别在哪?

acquire/release 能建立局部同步关系seq_cst 在此基础上,额外要求所有 seq_cst 操作看起来像落在一个全局单一顺序里,更容易推理。

可以这样说:

acquire/release 解决的是“该同步的地方同步上”; seq_cst 解决的是“所有线程看到的顺序也尽量一致”。 所以 seq_cst 更保守、更安全,也往往更慢。


4. 原子变量是不是就不需要锁了?

不是。 原子只适合单个共享状态或设计明确的无锁协议; 如果涉及多个变量的一致性、复合不变式、复杂临界区,锁通常更合适。

例如下面虽然每一步都是原子的,但整体逻辑未必正确:

if (balance.load(std::memory_order_relaxed) >= 100) {
    balance.fetch_sub(100, std::memory_order_relaxed); // 可能被并发破坏逻辑
}

这是典型的“复合操作不是原子事务”。


5. volatile 能替代原子或内存序吗?

不能。 volatile 在 C++ 里主要用于抑制编译器省略对特定对象的访问,常见于内存映射寄存器,不提供线程同步语义。 线程通信必须用原子、锁、条件变量等同步机制。


6. memory_order_consume 要不要讲?

面试中知道即可:

标准里有 consume,理论上比 acquire 更弱,基于依赖顺序; 但现实中大多数编译器基本按 acquire 处理,所以工程里几乎不用。 面试重点仍然是 relaxedacquire/releaseseq_cst


原理展开

1. 原子变量到底保证了什么,不保证什么?

保证的

原子变量保证单个原子操作不可分割,例如:

  • load
  • store
  • fetch_add
  • compare_exchange_*

这些操作不会出现“读到半个值”“写到一半被打断”这类问题。

不保证的

原子变量默认不保证你想要的跨线程观察顺序。 比如线程 A 先写 data 再写 flag,线程 B 看到 flag == true 时,未必已经能看到新 data,除非你用了合适的内存序建立同步关系。

所以面试里一定要点出:

原子变量解决的是“一个操作是不是原子的”, 内存序解决的是“多个操作跨线程怎么建立顺序和可见性”。


2. 为什么会有内存序:编译器重排 + CPU 乱序

如果没有内存模型,编译器和 CPU 为了性能会做大量优化:

  • 指令重排
  • 缓存延迟可见
  • 乱序执行
  • 写缓冲区延迟刷新

单线程下这些优化不会改变程序可观察语义; 但多线程下,不同线程看到的顺序可能就不同了。

例如下面逻辑顺序是“先写 data,再写 ready”:

data = 42;
ready = true;

但在没有同步的情况下,另一个线程未必按这个顺序观察到。 这正是内存序存在的原因:告诉编译器和硬件,哪些重排可以做,哪些不可以做。


3. happens-before、同步边与可见性

C++ 多线程能否正确,关键看有没有建立 happens-before

同步的基本形式

  • 同一线程中,前面的操作 sequenced-before 后面的操作
  • mutex.unlock() 与另一个线程成功 mutex.lock() 之间可建立同步
  • 原子变量的 release 写与对应的 acquire 读之间可建立同步

release-acquire 的直觉理解

  • release:把前面的写“推出去”
  • acquire:把后面的读“拉进来”
  • 读到了发布值,就相当于接上了一条同步边
int data = 0;
std::atomic<bool> ready = false;

void producer() {
    data = 42; // 普通写
    ready.store(true, std::memory_order_release);
}

void consumer() {
    if (ready.load(std::memory_order_acquire)) {
        // 此处读到 true,则能看到 producer 中 release 之前的写
        std::cout << data << '\n';
    }
}

注意一个常见面试坑:

不是“用了 acquire/release 就自动同步”, 而是必须对同一个原子变量形成配对关系,并且读取方确实读到了发布方写入的值。


4. 各种内存序该怎么理解

memory_order_relaxed

最弱,只保证该原子操作本身原子,不提供额外顺序保证。

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

void hit() {
    counter.fetch_add(1, std::memory_order_relaxed);
}

适合:

  • 统计
  • 计数
  • 监控指标
  • 不承担同步职责的状态位

不适合:

  • 用它来通知另一个线程“数据准备好了”

memory_order_release

用于写端。 保证该原子写之前的普通读写,不会被重排到它后面。

payload = make_payload();
ready.store(true, std::memory_order_release);

适合:

  • 发布数据
  • 设置完成标记
  • 无锁协议中的写侧提交

memory_order_acquire

用于读端。 保证该原子读之后的普通读写,不会被重排到它前面。

if (ready.load(std::memory_order_acquire)) {
    use(payload);
}

适合:

  • 读取发布标记
  • 消费已准备好的共享数据

memory_order_acq_rel

用于读改写操作,例如 fetch_addexchangecompare_exchange。 表示这次操作既带 acquire,又带 release。

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

void update() {
    state.fetch_add(1, std::memory_order_acq_rel);
}

适合:

  • 锁实现
  • 无锁状态迁移
  • CAS 循环中的状态更新

memory_order_seq_cst

最强。 除了 acquire/release 的效果,还要求所有 seq_cst 操作在全局上看起来存在一个一致顺序。

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

面试里不必展开抽象证明,但要会表达:

seq_cst 最容易推理,因为大家看到的顺序更统一; 代价是限制更多优化,某些平台上会带来更强屏障成本。


5. 什么时候选哪种:工程实践判断法

场景一:只关心值本身正确,不承担同步职责

relaxed

例如:

  • QPS 统计
  • 访问次数
  • 自增 ID(如果不依赖它同步其他对象)

场景二:线程 A 先写数据,再发通知;线程 B 收到通知再读数据

release/acquire

例如:

  • 发布-订阅
  • 单次初始化完成标记
  • 生产者设置“可消费”标记

场景三:逻辑复杂,团队对无锁内存模型不熟,优先正确性

先用 seq_cst,确认瓶颈后再优化

这是很重要的工程态度:

内存序不是越弱越高级。 先写对,再在有性能证据时把 seq_cst 下调到 acquire/releaserelaxed


场景四:多个共享变量要保持整体一致性

优先考虑锁,而不是强行用原子拼协议

因为这类问题本质上不是“一个变量怎么同步”,而是“多个状态如何维护不变式”。


6. 一个容易被问的经典例子

错误写法:两个都用 relaxed

#include <atomic>
#include <thread>
#include <iostream>

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

void producer() {
    data = 42;
    ready.store(true, std::memory_order_relaxed);
}

void consumer() {
    while (!ready.load(std::memory_order_relaxed)) {
    }
    std::cout << data << '\n'; // 不保证一定看到 42
}

问题在于:

  • ready 的原子性没问题
  • 但没有建立 data 写入到消费者读取之间的同步关系
  • 消费者看到 ready == true 时,data 仍可能是旧值

正确写法:release + acquire

#include <atomic>
#include <thread>
#include <iostream>

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

void producer() {
    data = 42;
    ready.store(true, std::memory_order_release);
}

void consumer() {
    while (!ready.load(std::memory_order_acquire)) {
    }
    std::cout << data << '\n'; // 保证看到 42
}

7. 性能怎么谈,面试里怎么说更稳

不要把它说成“seq_cst 一定慢很多”。更准确的说法是:

一般来说,约束越强,可允许的重排越少,编译器和硬件优化空间越小,某些架构上需要更强的内存屏障,所以性能可能更差。 但具体差异和硬件架构、编译器实现、操作类型、竞争程度都有关系,不能脱离场景绝对化。

面试里这样回答会更专业:

  • x86 上由于内存模型相对强,某些 acquire/release 的成本看起来没那么夸张
  • ARM 等弱内存模型平台上,差异往往更明显
  • 所以不要只在本机上拍脑袋判断,应基于目标平台测试

对比总结

概念保证内容是否建立跨线程同步典型场景优点缺点
memory_order_relaxed只保证原子性计数器、统计指标开销小、性能好不能同步其他数据,最容易被误用
memory_order_release当前原子写之前的操作不重排到后面需与 acquire 配对发布数据、完成标记建立写侧同步,开销适中单独使用没有意义,必须配对理解
memory_order_acquire当前原子读之后的操作不重排到前面需与 release 配对读取标记、消费已发布数据建立读侧同步,常用于发布-订阅只有读到对应发布值才真正同步
memory_order_acq_rel同时具备 acquire + releasefetch_addexchange、CAS 更新适合读改写场景推理难度高于普通发布-订阅
memory_order_seq_cst原子性 + 同步 + 全局一致顺序观感复杂同步、默认安全选择最容易推理,最不易出错约束最强,可能影响性能
原子变量单个操作不可分割不一定单变量状态、无锁基础构件比锁轻量,适合明确协议无法天然维护多个变量的一致性
互斥锁临界区整体串行化多变量一致性、复杂共享状态最容易保证正确性有阻塞、上下文切换成本
volatile防止某些访问被优化掉MMIO、信号处理特殊场景适合硬件寄存器语义不能做线程同步,面试高频误区

易错点

  • 把“原子”理解成“所有并发场景都线程安全”
  • 认为 relaxed 看到最新值后,也能顺便看到相关普通变量的新值
  • 不知道 release/acquire 必须围绕同一个同步原子来建立关系
  • 忽略“读取方必须真的读到发布方写入的值”这个前提
  • seq_cst 说成“绝对正确,且永远应该默认使用”
  • volatile 当成线程同步工具
  • 用原子去硬拼多变量事务一致性,结果代码难维护还容易错
  • 只会背结论,不会解释“为什么要有内存序:重排 + 可见性 + 内存模型”
  • 以为 fetch_add 之类读改写默认就等价于业务上的“整体原子事务”
  • 看到自旋等待就写空循环,不考虑让步、回退或更高层同步原语

记忆技巧

  • relaxed:只保“自己”,不管别人怎么观察 关键词:原子,不同步

  • release / acquire:一发一收,建立同步边 关键词:发布 / 订阅

  • seq_cst:大家都像按同一条时间线在看 关键词:全局顺序

可以记成一句话:

relaxed 只保操作不撕裂, release/acquire 保线程间交接, seq_cst 再加一层全局顺序。

再记一个选型口诀:

能不用原子协议就用锁; 能用 release/acquire 就别上来全 seq_cst; 能确认只需计数,就放心 relaxed


面试速答版

原子变量只保证单个原子操作不可分割,不自动保证多个操作在不同线程之间看到的顺序一致。 内存序就是用来约束编译器和 CPU 的重排,以及控制一个线程的写什么时候对另一个线程可见

relaxed 只保证原子性,不建立同步关系,适合计数器、统计值。 release/acquire 用来建立发布-订阅关系:写线程先写普通数据,再 release 一个标志位;读线程 acquire 读到标志位后,就能看到之前写的数据。 seq_cst 约束最强,除了同步,还给出全局一致顺序观感,最容易推理,但一般性能代价更高。

工程上通常是: 只计数用 relaxed,线程间交接数据用 release/acquire,逻辑复杂优先 seq_cst 或直接用锁。


面试加分版

原子变量和内存序要分开看。 原子变量解决的是单次操作的原子性,比如 load/store/fetch_add 不会被打断;但多线程真正难的是顺序性和可见性,也就是线程 A 做了几步写入,线程 B 最终按什么顺序观察到,这由 C++ 内存模型和内存序决定。

为什么需要内存序?因为编译器和 CPU 都会为了性能做重排和乱序执行。单线程下这些优化没问题,但多线程下,如果没有同步约束,A 线程逻辑上“先写数据、再写标志”,B 线程未必就会先看到数据再看到标志。所以内存序本质上是在规定:哪些重排允许,哪些不允许,以及如何建立跨线程可见性。

具体来说,memory_order_relaxed 只保证原子性,不承担同步职责,所以适合计数器、统计信息这类场景; memory_order_release/acquire 是最常见的线程通信方式,比如发布-订阅。写线程先写普通数据,再对原子标志 store(release);读线程 load(acquire) 读到这个标志后,就能看到发布前的普通写入,这背后其实就是建立了 happens-before 关系; 而 memory_order_seq_cst 更强,它不仅保证同步,还要求所有 seq_cst 原子操作看起来像处在一个全局一致顺序里,因此最容易推理,但优化空间更小。

工程上我一般这样选: 如果只是一个计数器,不承担同步语义,就用 relaxed; 如果要在线程间发布数据,就优先 release/acquire; 如果逻辑复杂、团队对无锁协议不够熟,先用 seq_cst 甚至直接上锁,保证正确性,再根据性能测试决定是否下调内存序。 因为很多并发问题不是“一个变量原不原子”,而是“多个共享状态如何保持一致”,这种场景锁往往比手写原子协议更稳。