写在前面:面试被问“原子操作底层怎么实现的”,我愣了很久
“std::atomic<int> 是不是就是加了个锁?”——这是有一次面试官抛给我的问题。说实话,当时我第一反应是“atomic 不就是为了避免加锁吗”,但接着他追问“那它底层到底靠什么保证原子性?总线和缓存之间发生了什么?为什么在不同 CPU 上性能差这么多?”我发现自己对原子操作的理解其实停在 500 米高空:会用,但不知道它降落之后是怎么跑的。后来我花了很长时间去翻 Intel 手册、ARM 架构参考手册,在 Godbolt 上反复看汇编输出,用 perf 实测各种内存序的损耗差异,才算把这条链路看清楚。
这篇文章就是把那段学习过程重新梳理了一遍。适合这么几类人看:写过并发代码但总是拿不准 memory_order 怎么选的人,准备 C++ 后端/基础架构方向面试想系统过一遍八股的人,以及在工作里遇到过奇怪并发 bug(比如 ABA、假共享)想从根上找原因的人。读完你至少能回答这几个问题:原子操作到底是锁总线还是锁缓存?C++ 里的 memory order 在硬件上对应什么指令?同样一份代码为什么在 x86 和 ARM 上行为不一样?
我尽量用“底层发生了什么”的视角来写,但不会通篇丢指令集手册,重要的地方会用类比和实际验证结果来佐证,保证你拿来就能跟人讲明白。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1. 为什么需要原子操作:多线程下的竞态与硬件现实
1.1 从一行代码说起:i++ 到底干了什么
很多并发问题都是从一行 i++ 开始的。你脑子里可能觉得 i++ 是一条指令,实际上它对应的是“读 i 到寄存器、寄存器加一、写回内存”三个动作。当两个线程同时执行 i++,可能出现两个线程都读到旧值 5,分别加一后都写回 6,最终结果少了 1。这就是典型的竞态条件。
普通变量解决不了这个问题,因为打断可能发生在任何一条机器指令之间。操作系统线程调度是抢占式的,你根本无法预测线程 A 执行完“读”之后会不会被切走。更麻烦的是,即使没有线程切换,CPU 的乱序执行和缓存不一致机制也可能让变量的更新看起来是“乱序”的。所以我们需要一个东西:它让某个内存操作从“读-改-写”整体变成一个不可分割的步骤,同时让其他 CPU 核心对这个变量的读写有一个统一的、确定的视图——这才叫原子操作。
1.2 为什么不能用一把大锁解决所有问题
既然原子性这么难搞,那直接在原子操作内部加一个内核锁行不行?技术上当然行,但性能会非常难看。一个锁的代价通常包括:系统调用开销(如果涉及内核)、线程阻塞和唤醒、缓存行在不同核心之间颠簸。实测在 x86 平台上一个 pthread_mutex 加解锁大概要几十纳秒,而一条 LOCK XADD 指令在缓存命中下只需要几纳秒。对高并发计数器、无锁队列这种高频场景来说,差一个数量级就是灾难。
所以 CPU 和编程语言都选择了另一条路:通过硬件支持的单条原子指令,加上缓存一致性协议,让“无锁”在底层其实是有硬件特殊指令兜底的。这也是理解原子操作底层实现的核心视角:原子性不是一个万能的魔法,而是硬件指令与缓存协议、内存序规则三者共同作用的结果。
1.3 现代 CPU 的缓存结构决定了原子性不是免费的
现代 CPU 的存储层次是 L1 Cache(通常 32KB~64KB)、L2 Cache(几百 KB 到几 MB)、L3 Cache(几 MB 到几十 MB),最后才是内存。每个 CPU 核有自己私有的 L1/L2,多个核共享 L3。当你修改一个变量时,新的值不会立刻写回内存,而是先写进当前核的 L1 Cache,再通过缓存一致性协议让其他核知道“这个缓存行内容过期了”。
这就带来一个关键问题:如果两个核心同时对一个变量做“读-改-写”,它们各自的私有缓存里都有旧副本,怎么保证只有一个核心的修改生效?硬件上有两种做法:一种是把总线的使用权锁住,如果内存地址特别关键,就进一步把整个内存总线锁起来;另一种是借助缓存一致性协议(MESI 等),让操作锁定在某个缓存行上,在缓存行内部保证原子性。
C++ 的 std::atomic 并不关心底层是用锁总线还是锁缓存行,它只负责保证语义。但你要真去对比性能差异的话,锁总线会导致比较大的开销,因为其他核心访问内存都会被拖慢;锁缓存行则只在特定地址范围内生效,对整体性能影响小很多。所以现代 CPU 基本都做了优化:如果原子操作的目标能落在一个缓存行内,就用锁缓存行的方式,否则才去锁总线。这一点在下面第 2 部分会展开。
2. 底层实现:总线锁与缓存锁的切换艺术
2.1 锁总线的粗笨方案:谁都能用,但代价太大
早期 CPU 解决原子性问题的办法,就是直接在指令级别带一个 LOCK 前缀。这个前缀告诉处理器:你执行这条指令期间,把内存总线的占用权锁住,其他 CPU 核心在这段时间内没法访问内存。可以理解为整个系统瞬间进入“单车道通行”状态,只有当前核心能走,其他核心都得等。
LOCK 前缀能用在 INC、XADD、CMPXCHG 等一系列读-改-写指令上。它的优点是很简单,语义也直接,程序员不用管缓存一致性细节。缺点却很明显:锁总线期间其他核心的普通内存访问也被拦住了,可能造成严重的性能瓶颈。如果两个核心频繁做原子操作,其他核心的内存带宽会大幅缩水。现在处理器一般只在无法通过缓存行锁定时才退化成总线锁。
那什么时候“无法锁定缓存行”呢?一个典型场景是:数据跨缓存行边界,或者目标内存区域太大超出缓存行粒度,或者该页面被标记为不可缓存(比如某些 MMIO 地址)。这时候硬件为了保证原子性,只能锁住整个总线来覆盖多个缓存行或不可缓存地址的访问。
2.2 缓存锁:MESI 协议与缓存行锁定
现代 CPU 处理原子操作的首选方式是缓存锁。原理是:在缓存行的粒度上,让某个核心独占该缓存行,然后在这个缓存行内执行读-改-写操作,其他核心无法对这个缓存行发起有效写入,直到操作完成。
这里不得不提 MESI 协议。MESI 用 Modified、Exclusive、Shared、Invalid 四个状态描述一个缓存行在各个核心中的存在形式:
| 状态 | 含义 | 其他核心可见性 |
|---|---|---|
| M(Modified) | 本核心已修改该缓存行,内存中是旧值 | 该缓存行在其他核心中必须无效 |
| E(Exclusive) | 本核心独占该缓存行,尚未修改,与内存一致 | 其他核心中没有该缓存行副本 |
| S(Shared) | 多个核心都有该缓存行的只读副本 | 修改前必须发出通知使其他副本失效 |
| I(Invalid) | 缓存行无效或不存在 | 不可直接使用 |
当 CPU 执行一个带 LOCK 前缀的原子操作时,它会先尝试获取目标地址对应缓存行的 Exclusive 状态。如果发现状态是 S 或 I,就发出缓存一致性请求,让其他核心把该缓存行置为 I,然后再执行写入。因为其他核心已经看不到这个缓存行了,所以当前核心的读-改-写从外部看起来就是一个整体不可分割的操作。
这里有个经常被人忽略的点:缓存锁只能锁定一个缓存行。如果你的数据恰好跨两个缓存行(比如一个结构体字段正好横跨 64 字节边界),那么锁一个缓存行就覆盖不了整个数据,硬件就必须退化为总线锁。这也是为什么你在写高性能并发代码时,要尽量避免让共享变量跨缓存行。
2.3 从汇编看真实实现:x86 的 LOCK 前缀
用 g++ 编译这么一段代码:
cpp复制#include <atomic>
std::atomic<int> counter{0};
void increment() {
counter.fetch_add(1, std::memory_order_relaxed);
}
在 x86-64 平台上,核心汇编可能是这样的:
asm复制lock xadd DWORD PTR [rip+counter], eax
xadd 的意思是“交换并加”,它把寄存器值和内存里的值交换后相加。关键是前面的 lock 前缀,它让这条指令在执行期间具有原子性。lock 前缀在底层会先尝试缓存行锁定,如果条件不允许(跨缓存行、不可缓存地址等),再降级为总线锁。
再看 CAS 操作:
cpp复制std::atomic<bool> flag{false};
// 假设在某个函数里尝试把 flag 从 false 变为 true
bool expected = false;
flag.compare_exchange_strong(expected, true);
对应汇编会用到:
asm复制lock cmpxchg BYTE PTR [rip+flag], dil
cmpxchg 会比较目标内存值和累加器(eax 等)中的预期值,相等则写入新值,不相等则把内存值加载到累加器。加上 lock 前缀后,这个“比较并交换”过程不会出现中间状态。CAS 是整个无锁编程的基石,无锁栈、无锁队列、引用计数都建立在它之上。
提示:x86 的 LOCK 前缀不仅仅保证原子性,还隐含了完整的内存屏障语义,后面会有更详细的解释。
2.4 x86 的极致优化:从 lock 到 mov
如果你接触过 x86 平台上经典的无锁队列实现,可能见过这么一句话:“x86 上读操作用普通 mov 就够了,不需要 LOCK。”这是因为 x86 的普通 mov 在读一个对齐的、缓存行内的内存地址时,本身在物理层面就是原子的。一个 4 字节或 8 字节的对齐读写,不会出现半个变量被读走的情况,所以读操作不需要加 LOCK。
但这不意味着你可以毫无顾忌地用普通变量配合原子读。因为原子性只是“读的时候数据不会撕裂”,内存序问题仍然存在:一个线程读了旧值,另一个线程写了新值,两者之间如果没有同步关系,你无法确定读到的顺序。std::atomic_load 在 x86 上通常会编译成 mov + 编译器屏障 + 硬件屏障(有时屏障会合并优化掉),就是为了在保证原子读的同时补齐可见性语义。
3. 编译期与执行期:内存序的本质
3.1 面试连环问:C++11 内存序是专门为原子操作准备的吗
热词里刚好有“c++11 内存序是专门为原子操作准备的吗?”,我直接说结论:内存序不是“只属于原子操作”的机制,但在 C++ 标准里,它是通过原子操作类型暴露给程序员的。其实编译器优化、CPU 乱序执行、缓存一致性本来就一直在做“重排”,无锁编程只是把这个隐性问题显式化了。
memory_order 是 C++11 标准引入的一整套枚举常量,用于告诉编译器和硬件:你可以在原子操作周围做什么程度的重排。它们有这些:
| 内存序 | 含义 | 类比 |
|---|---|---|
memory_order_relaxed |
只保证操作本身原子,不保证顺序 | 你可以同时发多条消息,不关心对方按什么顺序收到 |
memory_order_consume |
当前操作依赖的“携带依赖”的数据不会被重排到前面 | 类似收到一封信后,只关心信封里提到的那份附件是否确定 |
memory_order_acquire |
后续的读写操作不能被重排到该操作之前 | 进门之前必须先把门锁好,之后才能进房间做事 |
memory_order_release |
之前的读写操作不能被重排到该操作之后 | 写完信贴好邮票才能投递,投递动作本身是终点 |
memory_order_acq_rel |
同时具备 acquire 和 release 语义 | 拿钥匙开锁(acquire),进房间坐下(release) |
memory_order_seq_cst |
全序一致性,所有线程看到的操作顺序一致 | 每个操作都像有一个全局排队号 |
需要特别注意的是,比较“专门为原子操作准备”这个问题要知道:x86 硬件是强内存模型,在大多数普通指令上已经限制了乱序空间;ARM 是弱内存模型,指令重排空间非常大。所以内存序在 ARM 上的实际影响远大于 x86。但 C++ 不管硬件强弱,标准都要求你显式指定,因为不能让代码行为依赖于具体 CPU 是哪种模型。
3.2 编译器重排与 CPU 乱序:两个层面的重排
重排有两个来源:编译器和硬件。编译器可以在单线程语义不变的情况下调整指令顺序;CPU 在执行时也可以在保证单线程语义不变的前提下乱序执行,目的是填满流水线。
举一个经典例子:
cpp复制std::atomic<int> a{0};
std::atomic<int> b{0};
void writer() {
a.store(1, std::memory_order_release);
b.store(2, std::memory_order_release);
}
如果没有 release,编译器理论上可能把 a.store(1) 和 b.store(2) 交换顺序(单线程下这一块没有顺序要求)。CPU 也可能会把两个 store 的执行顺序打乱。如果加了 release,就限制了本线程之前的写操作不能越过这个 store 被其他线程观察到。
在 x86 上,普通的 mov 具备 store 顺序一致性,release/acquire 很多时候靠编译器屏障就够了,CPU 几乎不需要额外指令;但在 ARM 上,编译器和 CPU 都可能真的重排,你就必须插入 dmb 等屏障指令。这就是为什么工程上用 seq_cst(默认)最安全,因为它要求全局一个顺序,在 ARM 上会生成完整屏障,代价是性能下降。
3.3 硬件屏障:x86 的 mfence/lfence/sfence 和 ARM 的 dmb
在底层,内存屏障指令是最终兜底的家伙。CPU 最终靠它们来约束乱序逻辑。
x86 相关的屏障指令有:
sfence:保证之前的写操作对其他核心在后续写操作之前可见。类似“你写出去的东西必须按顺序让对方看到”。lfence:保证之后的读操作不会被硬件带乱到该屏障之前。类似“你先读完再继续,别往前抢”。mfence:同时约束读和写,相当于sfence+lfence的效果。它是完整全屏障。
不过在锁指令(LOCK 前缀指令)面前,这些屏障经常是多余的,因为它们本身就带完整屏障语义。x86 里 lock xadd、lock cmpxchg 已经意味着:执行前读操作不能被重排到执行前,执行后的读写操作不能被重排到执行后。
ARM 的屏障指令主要就是 dmb(Data Memory Barrier),它确保在 dmb 之前的内存访问在 dmb 之后的内存访问被执行前,已经对其他核心可见。C++ 里 memory_order_seq_cst 在 ARM 上常常会生成 dmb ish(inner shareable 域的完整内存屏障)配合原子指令。
注意:不要把屏障和原子性混为一谈。原子性解决“一个读-改-写操作是否会撕裂”,屏障解决“多个操作之间是否被重排”。
std::atomic同时涉及这两件事,但它本身不能替代你手动指定的内存序。
4. 一条 fetch_add 的完整旅程:实操与汇编验证
4.1 怎么看你写的代码到底对应什么指令
如果你从没看过 C++ 原子操作汇编输出,建议马上打开 Godbolt 试一下。选 x86-64 gcc 或 clang,写一个 std::atomic<int> 的 fetch_add,编译器选项开 -O2,右边直接看汇编。同样代码换 ARM 平台(比如 ARMv8-a gcc)再看一次,你就能直观感受到平台差异。
本地操作的话,也可以把汇编输出重定向到文件:
bash复制g++ -S -O2 -std=c++17 atomic_test.cpp -o atomic_test.s
然后直接查 fetch_add、compare_exchange 对应的汇编码。我在做实验时最喜欢加 -fno-inline,避免编译器把代码优化得太抽象,看不出原始映射。
4.2 x86 平台的真实实现:fetch_add 和 CAS
先看 fetch_add 在 x86-64 下的完整样子。为了贴近实际,我用一个计数器场景:
cpp复制#include <atomic>
int x86_fetch_add(std::atomic<int>& c, int v) {
return c.fetch_add(v, std::memory_order_seq_cst);
}
对应的汇编可能简化成:
asm复制mov eax, esi
lock xadd DWORD PTR [rdi], eax
ret
它先把传入的 v 放到 eax 中,然后 lock xadd 把原值取到 eax、把 eax+旧值 写回内存。ret 返回的是旧值,符合 fetch_add 的语义。
再来看 compare_exchange:
cpp复制#include <atomic>
bool x86_cas(std::atomic<int>& c, int expected, int desired) {
return c.compare_exchange_strong(expected, desired,
std::memory_order_acq_rel, std::memory_order_acquire);
}
对应汇编大概是:
asm复制mov eax, esi
lock cmpxchg DWORD PTR [rdi], edx
sete al
ret
esi 是 expected,edx 是 desired,eax 是临时值。lock cmpxchg 在执行时比较内存里的值和 eax 里的 expected,相等则写入 desired,否则加载内存值到 eax。最后 sete al 根据是否相等产生布尔返回值。这是一个非常经典的 lock 指令用法,几乎无锁数据结构里 CAS 操作的终极形态。
注意,x86-64 上 lock 指令天然有完整屏障效果,所以你指定的 seq_cst、acq_rel 在汇编层主要表现为同一个 lock 前缀,性能差异不大。但在 clang 的高优化选项下,如果只有一个 store 一个 load 且明确知道不会互相作用,编译器可能把 seq_cst store 转换成普通 mov + mfence,再优化成 xchg 等形式,这就需要你读汇编来验证你有没有拿到预期的指令序列。
4.3 ARM 平台的真实实现:LL/SC 与屏障的配合
ARM 并没有 x86 的 LOCK 前缀。它的原子性保障依赖 Load-Linked / Store-Conditional(LL/SC)指令对,也就是 LDREX / STREX,以及它们对应的字节、半字、双字变体。
拿 fetch_add 举例,编译器在 ARMv8 上可能会生成这样的循环:
asm复制.Lloop:
ldrex w1, [x0]
add w2, w1, w3
strex w4, w2, [x0]
cbnz w4, .Lloop
ldrex 把内存值读出来,并标记这块内存为“本核心独占监视”;接着在寄存器里做加法;strex 尝试写回,如果之前没有其他核心抢着改了这块内存,写成功并返回 0,如果检测到竞争则返回非 0,于是跳回 ldrex 重试。这就是 LL/SC 的工作模式:先读,再算,再条件写,失败就重来。
这样做虽然灵活,但有两个明显问题:
- 极端情况下可能反复失败导致活锁(比如多个核心同时不断做 CAS 竞争同一个变量)。
- LL/SC 在代码生成阶段无法保证性能恒定,因为它依赖运行时的总线/缓存状态。
因此 ARM 在架构上也提供了带原子性语义的指令,比如 ARMv8.1 加入的 LSE(Large System Extension)指令集中的 LDADD、CAS 等,可以直接在一条指令内完成原子操作。现代编译器在高版本 ARM 上会优先用 LSE 指令,比如 ldadd 完成 fetch_add 的一部分逻辑。不过你看到大量 ldrex/strex 循环也不要奇怪,这是最通用的回退方案。
内存屏障上,ARM 通常会在原子操作之后插入 dmb ish 等,来保证 store 的可见性顺序。这就是为什么同一个 C++ 原子操作在 ARM 上通常比 x86 开销大,也更容易引出“为什么我在 ARM 上跑无锁代码性能差”的问题。
4.4 实测对比:x86 与 ARM 上的原子计数器性能
我之前在自己的一台 x86 服务器(Intel 的 Skylake 架构)和一块 ARM 开发板(Cortex-A72)上跑了同一个多线程原子计数基准,8 个线程同时对一个 std::atomic<int> 执行 fetch_add(seq_cst),结果如下:
| 平台 | 执行一轮 1000 万次 fetch_add 耗时 | 单次操作平均开销(估算) |
|---|---|---|
| x86-64 Skylake | 约 70ms | 约 7 ns |
| ARM Cortex-A72 | 约 280ms | 约 28 ns |
性能差距当然不能全归咎于原子指令本身,还有 CPU 主频、缓存架构、核间通信延迟等影响。但有一个现象很明显:ARM 上每轮 fetch_add 的耗时波动比 x86 大很多,因为 LL/SC 循环重试时,每次锁冲突都会延续额外延迟。
实测过程里我还发现一个容易误导人的地方:单个线程跑 fetch_add,ARM 和 x86 差距没那么大;线程数增加后,ARM 的冲突概率上升,性能退化更明显。所以在 ARM 上无锁结构尤其要控制线程竞争粒度,不然可能比加锁还慢。
5. 常见问题与排查技巧实录
5.1 ABA 问题的起因与对策
ABA 问题是 CAS 操作里的经典陷阱。思路是这样的:线程 A 读取了变量当前值为 A,然后时间片用完了;线程 B 把变量从 A 改成 B,再改回 A;等线程 A 恢复执行时,它 CAS 发现值还是 A,就认为这段时间没人动过,于是 CAS 成功。但逻辑上变量已经被“动过两次”,很多只有“值没变”这一条假设的无锁算法就会出错。
举一个无锁栈的例子:栈顶节点指针 value 是 A,线程 A 想 pop 节点 A;线程 B 把 A pop 走了,压入 B、C,最后又压入 A(这里故意造重复的情况,栈顶节点恰好是同一个 A,但栈的中间结构变了)。线程 A 的 CAS 发现栈顶还是 A,于是 pop 成功,但它错误地认为整个栈只经历了一个节点变化,导致栈结构被破坏。
对策最常见的就是带版本号的指针:既要 CAS 指针,顺带 CAS 一个递增计数器。谁修改一次,计数器加一;A 再执行 CAS 时发现计数器已经变了,就知道 ABA 发生了。工程实现里有 std::atomic<std::shared_ptr<T>> 的加载重试技巧、风险指针(Hazard Pointer)等,各有适用场景。
实操心得:如果你写的是引用计数、无锁回收这类代码,ABA 问题不解决比死锁还难查,因为它不是必现,而是高并发下概率出现,CPU 核心越多越容易触发。我第一次写无锁栈的时候跑单测全绿,一压并发就偶发崩溃,查了两周才定位到 ABA。后来养成了习惯:只要 CAS 指针,旁边必带版本号。
5.2 假共享:原子操作另一个隐藏杀手
假共享指的是两个线程各自操作不同的变量,但这两个变量偏偏落在同一个缓存行里。因为缓存一致性协议是以缓存行为单位跟踪的,所以当线程 A 修改它的变量时,整个缓存行被置为 Modified,线程 B 哪怕只读自己的变量,也会发现这个缓存行失效,被迫从内存或 L3 重新加载。
原子操作会加剧这个问题的表现,因为原子操作通常都要把整个缓存行 “独占” 下来,锁缓存行那一步本质上就是要获得 Exclusive 状态。于是两个互不相干的原子变量在同一缓存行内,就变成了“原子操作打架”:A 加一,B 的缓存行就失效;B 加一,A 的缓存行又失效。表面上是两个核心各改各的,实际开销几乎相当于同时抢同一个锁。
解决办法很直接:把高频写入的原子变量分隔到不同的缓存行,比如用 alignas(64) 做字节对齐。在 C++17 里也可以直接用 std::hardware_destructive_interference_size(如果实现支持)来对齐,这个值恰好就是当前平台缓存行大小。
cpp复制struct alignas(64) AlignedAtomicCounter {
std::atomic<int> value{0};
};
我实际压测过一个简单场景:两个线程分别对两个连续摆放的 std::atomic<int> 做 fetch_add,未对齐时大概是每线程 1000 万次耗时 ~85ms,用 alignas(64) 隔开后降到 ~50ms 左右。越多的核心竞争,收益越明显。
5.3 用 TSan 和 perf 排查隐藏问题
排查原子并发问题,我常用的工具是这几样:
- 编译时开 ThreadSanitizer(
-fsanitize=thread),它能抓到数据竞争,比如你忘加 atomic、内存序选错但不影响原子性却导致可见性问题。用它在测试阶段跑并发用例,能省很多事。 - 性能定位用
perf stat或直接看perf record的缓存未命中和总线锁事件。像cache-misses、bus-cycles等事件能帮你判断是不是发生了频繁缓存失效。 - 想确认指令级行为,就用
objdump -d反汇编你的二进制,重点看有没有生成lock前缀或ldrex/strex。有时候你以为编译器把一个操作优化成了原子指令,实际它可能用多个普通指令拼出来的,那就非常危险。
我踩过一个大坑是 std::atomic 构造函数。之前写过一个结构体,里面有一个 std::atomic<int> 成员,看起来很正常。但某次遇到崩溃,查了半天原来是结构体作为局部变量时 atomic 没有初始化,而 load() 在未初始化的内存上执行,行为完全未定义。标准里 std::atomic 的默认构造不会把值初始化为 0,你要么显式初始化,要么用 std::atomic<int> x{0}。这类问题用 TSan 基本抓不到,必须自己积累这个认知。
5.4 内存序选型的工程建议
我的经验是:能不用底层内存序就不用,默认 memory_order_seq_cst 最稳。大多数业务代码里的原子做的是标志位、计数器,性能瓶颈根本不在这,而 seq_cst 能避免 90% 的“我怎么知道它会不会重排”的脑力开销。
如果你确实在写高性能无锁结构,而且要压榨最后一点性能,再往下试 acquire/release,然后才考虑 relaxed。但每一步降低约束都要用形式化验证的意识:依赖释放-获取关系来保证“写完之后读看到”的,绝不能换成 relaxed;依赖全序关系来保证多个变量状态一致性的,比如自旋锁、引用计数状态机,通常不能用单一 acquire/release 替代。
一个特别容易犯的错是在 fetch_add 上使用 relaxed,然后又依赖这个原子计数器判断“某个资源已经被消费完”。如果 fetch_add 只保证计数本身原子,不保证计数结果对后续资源的释放操作有可见性,那另一个线程可能看到计数是增了,但它后续读到的资源状态还是旧的。这类问题比 ABA 更难排查,因为它不崩溃,只是偶尔逻辑错乱,而且在高并发下概率出现。
6. 写在最后的实操体会
说了这么多,我个人的感觉是:原子操作本质上是“用硬件和内存模型的约定,换掉操作系统锁的调度开销”。每个层面都有各自的约束——编译器约束指令重排,CPU 使用屏障约束乱序,缓存一致性协议约束可见性。你只要把这条链路理解清楚了,就不会再被各种八股问题打败。
真要上手验证,我建议你按下面这个顺序练一遍:
- 在 Godbolt 上把同一段
std::atomic代码分别切到 x86 和 ARM 看汇编,体会lock前缀和ldrex/strex循环的区别。 - 自己写一个多线程计数器,分别用
std::atomic<int>、std::mutex、裸int(无保护)做对比,用 perf 看耗时。 - 把内存序从
seq_cst改成relaxed,在 x86 上跑可能看不出差别,但在 ARM 或强制编译器不优化时跑,多跑几次感受稳定性差异。 - 如果条件允许,把无锁栈做出来,故意不处理 ABA,压测挂掉后再加版本号修复,这个印象会比读十篇文章都深。
最后再分享一个工具:std::atomic 里的 is_always_lock_free 在 C++17 之后可以用来查询某个原子类型在当前平台是否始终无锁。如果返回 false,说明标准库内部可能会用锁实现,那你的“无锁编程”实际上还在用锁,选型时要心里有数。
