很多人第一次接触 std::atomic,是在多线程计数器这种极简例子上。两个线程同时对一个全局 int 执行 ++,循环各 10 万次,跑完结果经常只有 16 万左右。换上 std::atomic<int> 之后,结果神奇地归位了。但当时大多数人只是“知道要这么写”,并没有真正想明白一件事:CPU 到底是靠什么把“读、改、写”三个步骤焊成一个不可分割的整体?
后来我把这事彻底搞明白,是在一次面试被问到“讲讲 C++ 原子操作的底层实现,最好能说到 CPU 那一层”的时候。当场卡壳。回去之后啃了三样东西:Intel 开发者手册、ARM 架构参考手册,还有 C++ 标准里的内存模型章节。这篇文章就是把当时啃明白的东西整理出来,给同样被“原子操作”四个字搞得头大的人一份从硬件到编译器的完整底层视角。适合正在学 C++ 多线程的读者、准备 C++ 面试的人,以及那些写了很久 std::atomic 但没看过一眼反汇编的工程师。
1. 先把“原子性”这个概念拆开看
1.1 一个计数器的诡异结果,以及一条 ++ 背后的三次内存访问
先回到开头的例子。一个普通 int 的 ++,在 x86-64 上通常对应三条汇编:
asm复制movl (%rdi), %eax ; 把内存中的值 Load 到寄存器
addl $1, %eax ; 寄存器里加 1
movl %eax, (%rdi) ; 把新值 Store 回内存
问题就出在这里。线程 A 执行完 movl (%rdi), %eax 后,如果操作系统做了线程切换,线程 B 也去读同一个内存地址、加 1、写回,两边各写各的,最后只能保留一个结果。这就是经典的“丢失更新”。
但这还只是最表层的失败。就算 CPU 不再发生线程切换,让你一条指令一条指令连续执行完,结果也可能不对。原因在于缓存:线程 A 在核心 0 上把新值写进了核心 0 的 L1 缓存,线程 B 在核心 1 上读的却是自己缓存里的旧值。所以“原子”要解决的不只是“指令别被中断”,还包括“写的结果其他核心什么时候能看见”“能不能看见中间状态”这些更深层的问题。
1.2 原子性的四个层次,缺一不可
把“原子操作”拆开看,它其实包含四个层次:
- 指令级不可分割:对于单条指令,CPU 保证它在任何中断、异常、其他核心并发访问面前,要么不执行,要么完整执行完。
- 缓存一致可见:写操作完成后,其他核心最终能看到这个新值,并且不会看到“写了一半”的数据。
- 顺序约束:多个原子操作之间的先后关系需要有明确语义,否则程序逻辑照样会乱。
- 跨平台可移植:你写的是 C++ 代码,不能因为换了一颗 CPU 就从“对”变成“错”。标准库必须把不同硬件的差异全部抹平。
很多初学者以为 std::atomic 只是“给变量加了个锁”,这是最大的误解。实际上,C++ 标准没有规定任何锁的具体实现,它只规定了语义:读改写要么作为一个整体发生,要么完全不发生;同时你可以通过内存序参数,告诉编译器和 CPU“哪些重排允许,哪些不允许”。底层真正干活的,是 CPU 的缓存一致性协议、总线锁、缓存锁,以及编译器生成的内存屏障指令。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从总线锁到缓存锁:CPU 是怎么把“读改写”焊成一体的
2.1 总线锁:最直接但代价也最大
早期的 x86 处理器,或者说现在所有处理器处理“不可缓存内存”和“跨缓存行访问”时,用的都是最粗暴的方案:锁总线。
当 CPU 执行一条带 LOCK 前缀的指令时,它会向总线发出一个 LOCK# 信号,把系统总线死死摁住。通俗地讲,就是“我要开始读改写一组数据,这期间谁都不许访问内存”。其他处理器想读内存、写内存,都得等这个信号释放。
这个方案正确性毋庸置疑,但性能代价是毁灭性的。总线是内存和其他所有核心共享的关键路径,锁定总线等于让所有核心陪着当前指令一起排队,哪怕它们访问的内存地址完全不冲突。所以现代 CPU 只在两种情况下才会退化到总线锁:一是操作数无法被缓存,二是操作数跨越了缓存行边界(比如一个 8 字节的变量,头 4 字节在一个缓存行,后 4 字节在另一个缓存行)。这两类情况都比较罕见,绝大部分原子操作都走缓存锁。
2.2 MESI 协议:缓存锁的地基
要理解缓存锁,得先理解多核 CPU 的缓存一致性协议。目前最常见的模型是 MESI,每个缓存行有四种状态:
- M(Modified):本核心独享,数据被修改过,和内存不一致,但其他核心没有该缓存行的副本。
- E(Exclusive):本核心独享,数据和内存一致,其他核心没有副本。
- S(Shared):多个核心共享,数据一致。
- I(Invalid):本核心没有有效数据,读取时需要重新从内存或其他缓存获取。
当一个核心要写一个缓存行时,如果它手里只有 S 状态,它必须先发一个 RFO(Read For Ownership)请求,把其他核心手里的副本全部标记为 I,然后才能写。这个过程保证了“同一时刻只有一个核心能修改某个缓存行里的数据”。
缓存锁的本质,就是在单条指令执行期间,不允许其他核心对本缓存行发起任何有效请求。x86 上的做法是:如果操作数在 L1 缓存里且对齐良好,处理器就冻结这条缓存行,或者说让这条缓存行进入一种“独占并锁定”的状态,其他核心的读写请求要么等到操作结束,要么被缓存一致性协议直接回应为“无效”。这样一来,读改写三条子操作之间,其他核心根本看不到中间状态。
这里有一个非常重要的边界条件:原子性的最小粒度和缓存行一致。只要读改写的数据落在同一个缓存行内,缓存锁就能搞定;一旦跨了两个缓存行,硬件无法保证“同时锁住两个缓存行”不被别的核心交叉修改,处理器只能退回到总线锁。
2.3 x86 的 LOCK 前缀与 RMW 指令家族
x86 实现原子读改写,靠的是指令前缀 LOCK。有几种常见形式:
LOCK ADD:原子加法,常用于fetch_add。LOCK XADD:原子交换并加,fetch_add的典型映射。LOCK CMPXCHG:原子比较并交换,CAS(Compare-And-Swap)的核心指令。LOCK INC/DEC:原子自增/自减。
以 std::atomic<int>::fetch_add(1, std::memory_order_relaxed) 为例,在 x86-64 下 GCC 生成的就是:
asm复制lock addl $1, (%rdi)
一条指令搞定。硬件在指令执行期间,既保证了这个读改写不被其他核心打断,也把新值的可见性同步给了其他核心。更关键的是,LOCK 前缀在 x86 上同时充当了完整的内存屏障,它把缓冲的写操作全部排空,把无效化请求全部处理完,所以 seq_cst 的 RMW 在 x86 上不需要额外再插什么 fence。
CAS 稍微复杂一点。C++ 里的 compare_exchange_weak/strong 在 x86-64 上是这样实现的:
asm复制movl %eax, %ebx ; 把期望值放进 eax
lock cmpxchgl %edx, (%rdi) ; 比较并交换
cmpxchg 会先比较目标内存值和 eax,相等则写入新值,不相等则把当前值加载回 eax。LOCK 前缀保证了这整个过程原子性。这套设计最巧妙的地方在于:它不锁总线,而是通过缓存一致性协议和缓存行锁定来保证正确性,因此性能远好于老式的总线锁方案。
2.4 ARM 为什么走另一条路:LDREX/STREX 与 LL/SC 范式
ARM 没有采用 x86 那种一整套 LOCK 前缀指令,而是选择了 LL/SC(Load-Linked / Store-Conditional)范式。核心指令是两条:
LDREX:带链路标记的读。它会从内存读数据,并在当前核心上记录一个“独占监视器”状态。STREX:带条件的写。它会尝试写回数据,同时检查独占监视器是否仍然有效。如果在这期间有别的核心写过同一个地址,监视器就会被清除,STREX写失败,并通过返回值告诉软件“你没写成”。
一个典型的原子自增循环长这样:
asm复制.Lloop:
ldrex r2, [r0] ; 带链路读
add r2, r2, #1 ; 加 1
strex r3, r2, [r0] ; 尝试写回,r3 记录是否成功
cmp r3, #0
bne .Lloop ; 失败就重试
为什么要用“循环重试”而不是一条指令搞定?因为 ARM 的设计理念是把复杂度从硬件往软件转移:CPU 只需要提供一个轻量的监视器机制,至于冲突了怎么办、要不要重试,由编译器和开发者决定。相比之下,x86 的 LOCK 前缀更“霸道”,硬件直接保证整条指令不会被打断,软件侧不用管重试。这两种风格没有绝对优劣:LL/SC 在低冲突场景下非常省资源,x86 的单指令方案在实现无锁数据结构时则更简单直接。
到 ARMv8 时代,ARM 也提供了带内存序语义的变体:LDAXR(acquire load-acquire exclusive)、STLXR(store-release exclusive),后续的 LSE 指令集还加入了 LDADD、CAS 等更高级的单指令原子操作,这才算和 x86 站在了同一条起跑线上。
3. 内存序:原子操作真正难懂也真正值钱的部分
3.1 重排序是两层问题的叠加
很多人在学 C++11 内存序时都会问一句:内存序是专门为原子操作准备的吗?答案是:内存序这个概念本身来自 CPU 和编译器的重排序行为,C++ 只是把它形式化成了一套 API,而它最常见的载体就是 std::atomic。
即使硬件保证了单条原子指令的不可分割性,程序的执行顺序仍然可能被两层“乱序器”打乱:
第一层是编译器重排序。编译器在做指令调度、寄存器分配、公共子表达式消除时,会把互不相关的内存访问顺序打乱。比如你写:
cpp复制int a = 1;
int b = 2;
编译器完全可能先写 b 再写 a,只要它认为这不会改变程序语义。但在多线程环境下,这种“认为”就不可靠了——另一个线程可能正在观察 a 和 b 的状态。
第二层是CPU 乱序执行。现代 CPU 内部有多个执行单元,指令会被拆成微操作,按依赖关系并行执行,而不是按程序顺序执行。这种乱序对单线程无害,因为 CPU 会保证单线程的最终结果一致;但对多线程来说,其他核心看到的内存访问顺序,跟程序源码顺序并不一致。
很多人会想到用 volatile 来解决,这是另一个经典误区。volatile 只告诉编译器“别优化掉这个变量的读写”,它既不阻止 CPU 乱序,也不提供任何内存屏障保证,多线程环境下用它做同步完全不够格。
3.2 从 store buffer 到内存屏障:把乱序讲明白
CPU 为什么会乱序?与其背“乱序执行”这四个字,不如看一个具体机制:store buffer(写缓冲器)。
现代 CPU 的写操作不是直接写内存的,而是先进一个 store buffer,然后异步刷到缓存/内存。这样做是为了让写指令不用卡在等待内存响应上。但副作用是:一个核心自己还没落地的写,对别的核心不可见。这就导致了 x86 上真实存在的一种重排——StoreLoad 重排:
text复制核心 0:store X = 1,然后 load Y
核心 1:store Y = 1,然后 load X
两个核心各自把 store 放进 store buffer,然后立刻执行 load,因为 store 还没刷入缓存,两个 load 都可能读到旧值。经典的 Dekker 算法就是死在这里。
和 store buffer 配套的还有 invalidate queue(无效化队列)。当核心 0 要写缓存行时,它要发无效化消息给其他核心,其他核心为了不阻塞写请求,可能把无效化操作排进队列异步处理。结果就是:核心 0 的写已经“完成”了,但核心 1 读到的还是旧值。这又制造了 StoreLoad 之外的更多重排可能。
内存屏障(Memory Barrier / Fence)就是对抗这些机制的指令。x86 上有三兄弟:
sfence:保证所有之前的 store 在之后的 store 之前全局可见。lfence:保证所有之前的 load 在之后的 load 之前完成。mfence:同时具备上面两者的能力,相当于完整的屏障。
ARM 上则普遍使用 DMB(数据内存屏障)和 DSB,通过参数指定屏障的共享域和读写范围。老实说,背这么多指令记不住也没关系,C++ 的内存序参数已经帮你把“什么时候该插什么屏障”的活干完了。
3.3 C++ 的 memory_order 是对谁说的
C++ 的内存序是给编译器下的命令,要求编译器在生成代码时,既要阻止编译器自身的重排,也要在必要时插入 CPU 屏障指令。六个档位说白了就是“你愿意付出多少性能,换取多少顺序保证”:
memory_order_relaxed:只保证单个原子对象上的读改写是原子的,不做任何顺序约束,也不插入任何屏障。memory_order_consume:关于依赖链的轻量级 acquire,C++17 基本不建议使用,实际编译效果也常退化为 relaxed。memory_order_acquire:后续的读操作不允许重排到本操作之前。memory_order_release:之前的写操作不允许重排到本操作之后。memory_order_acq_rel:结合两者,主要用于 RMW 操作。memory_order_seq_cst:在 acquire/release 的基础上,额外要求所有线程观察到一个全序,实现代价最大。
注意,这里说的“不允许重排”包含两层意思:编译器不能做这种优化,CPU 也不能。但在 x86 这种强内存模型上,约一半的屏障指令是空操作,编译器不需要做任何事;在 ARM 这种弱内存模型上,编译器则必须老老实实地插入 DMB 之类的指令。
举个例子,memory_order_relaxed 下的 store,在 x86 和 ARM 上都只是普通地址读写吗?x86 上确实如此,但在 ARM 上编译器如果确认某个平台需要屏障,就必须加。C++ 内存序的真正价值,就是让你不用关心目标平台是哪一种,只要你按序声明意图,编译器负责翻译成正确的机器码。
3.4 一个真实反例:Dekker 算法为什么会双 0
用代码验证上面这些概念最直接的方式,是构造一个经典的“双 0”场景:
cpp复制std::atomic<int> x{0};
std::atomic<int> y{0};
// 线程 A:
x.store(1, std::memory_order_relaxed);
int a = y.load(std::memory_order_relaxed);
// 线程 B:
y.store(1, std::memory_order_relaxed);
int b = x.load(std::memory_order_relaxed);
如果程序是“顺序执行”的,那么无论怎么交错,a 和 b 至少有一个是 1——因为总有一个线程先完成 store,另一个线程的 load 至少能看到对方写入的值。但实测结果里,a == 0 && b == 0 是能稳定出现的。原因就是两个线程的 store 都先落进了各自的 store buffer,随后执行的 load 从缓存/内存里读到旧值 0。
把两个 store 改成 memory_order_seq_cst,x86 上编译器会在 store 后面插入 mfence,强制排空 store buffer,双 0 就消失了。这个例子虽然简单,但足够说明问题:原子性只保证单次读改写不被撕裂,顺序性则需要内存序来额外维护。
4. 反汇编看真身:std::atomic 在不同平台长什么样
4.1 准备:一段几乎零成本的验证代码
光看理论不过瘾,直接动手反汇编。不用装虚拟机,也不用交叉编译环境,打开 Compiler Explorer(godbolt.org),把编译目标分别设成 x86-64 和 ARM64 就行。本地操作也可以,以 Linux 上的 GCC 为例:
bash复制g++ -O2 -S -std=c++11 test.cpp
写个测试函数:
cpp复制#include <atomic>
std::atomic<int> counter;
int add_one() {
return counter.fetch_add(1, std::memory_order_seq_cst);
}
void store_value() {
counter.store(42, std::memory_order_seq_cst);
}
int load_value() {
return counter.load(std::memory_order_seq_cst);
}
4.2 x86-64:大多数操作“白嫖”硬件强模型
在 x86-64 上,GCC 生成的汇编大概是这样:
asm复制add_one:
lock xaddl %eax, counter(%rip)
ret
store_value:
movl $42, counter(%rip)
mfence
ret
load_value:
movl counter(%rip), %eax
ret
有几个细节值得注意:
fetch_add直接一条lock xadd,干净利落。seq_cst的 store 在普通mov之后加了mfence。原因前面说过:x86 允许 StoreLoad 重排,为了达到全序必须把写排空。seq_cst的 load 就是普通mov,没有任何 barrier。因为在 x86 的 TSO 内存模型下,load 不会和后面的读写重排,编译器只要别乱调度就行。
这个结果说明一件事:在 x86 上,memory_order_relaxed、acquire、release 的 load/store 在汇编层面的开销几乎为零,唯一区别只在于编译器是否允许你把它挪到别的位置去。
4.3 ARM64:每条指令都带着顺序语义
同一段代码,ARM64-GCC 生成的汇编就完全不一样了:
asm复制add_one:
ldaxr w0, [x0]
add w0, w0, #1
stlxr w1, w0, [x0]
cbnz w1, add_one ; stlxr 失败则重试
ret
store_value:
stlr w0, [x0]
ret
load_value:
ldar w0, [x0]
ret
ARM 上没有任何“白嫖”,每条带内存序的操作都用专门指令。ldar 是 acquire load,stlr 是 release store,ldaxr/stlxr 组合则是带 acquire/release 语义的 LL/SC 循环。注意这里 add_one 即使只是加 1,也要走完整的加载、修改、条件写回、失败重试循环,比 x86 的 lock xadd 啰嗦得多。
再来一份对比表,把常见操作在两种架构上的实现放一起看:
| 操作 | x86-64(seq_cst) | ARM64(seq_cst) |
|---|---|---|
| load | mov |
ldar |
| store | mov + mfence |
stlr |
| fetch_add | lock xadd |
ldaxr/add/stlxr 循环 |
| compare_exchange | lock cmpxchg |
ldaxr/cmp/stlxr 循环 |
| relaxed load/store | 普通 mov/mov |
普通 ldr/str |
| acquire load | mov |
ldar |
| release store | mov |
stlr |
4.4 性能差异的直觉:为什么同一个 C++ 程序在不同芯片上跑起来差这么多
了解汇编差异后,性能的直觉就有了。
- 在 x86 上,
std::atomic的 load/store,只要你不用seq_cst的 store,基本等同于普通读写,残留的开销主要来自编译器对优化机会的限制。 - 在 ARM 上,哪怕只是 acquire load,也要用
ldar这种专用指令,延迟通常比普通ldr高。所以同一个高并发程序,ARM 平台对内存序的选择更敏感。 - 跨核心竞争时,原子 RMW 的成本主要不在指令本身,而在缓存一致性协议:
lock xadd要把缓存行锁定并让其他核心的副本失效,然后等它们确认。所以无锁数据结构的性能瓶颈往往是缓存一致性流量,不是那条lock指令。
这些差异也解释了为什么面试官总爱追问“为什么 x86 上 relaxed 和 seq_cst 的 load 生成汇编一模一样”。因为表面一致的背后是硬件内存模型的差异,而对工程师来说,知道编译器在目标平台上把语义翻译成了什么,才算真正理解性能和安全性的边界。
5. ABA 问题:底层视角下的经典陷阱
5.1 无锁栈里的“幽灵节点”
现在来看一个和原子操作底层直接相关的经典陷阱:ABA 问题。先描述一个典型场景,无锁栈的 pop:
cpp复制while (true) {
Node* head = top.load(std::memory_order_relaxed);
if (head == nullptr) return nullptr;
Node* next = head->next;
if (top.compare_exchange_weak(head, next)) {
return head; // 弹出成功
}
}
如果进程里只有一个线程在 pop,这个逻辑是没问题的。但加入并发之后,它可能踩中 ABA:线程 A 读 head == A;线程 A 时间片用完被切走;线程 B 弹出 A,又弹出 B,然后压入一个内存地址恰好等于 A 的新节点;线程 A 恢复执行,compare_exchange_weak 比较 top 和 A,发现相等,CAS 成功。可此时栈顶指向的不再是原来的 A,而是新节点,head->next 已经被改成了完全不一样的东西。结果就是要么弹错节点,要么把栈结构彻底破坏。
为什么硬件没有帮我们拦住?因为 CAS 指令的职责只是“比较内存地址处的值和期望值,相等才写”。它既不知道 A 指向的那个对象是不是“原来的那个对象”,也无法感知对象的内容是否已经面目全非。缓存一致性协议能保证的是“这个地址上存的值是原子读写的”,但无法知道“这个值所代表的外部语义是否过期”。
5.2 为什么缓存一致性协议救不了 ABA
从底层来看,ABA 的本质是值的重用:一个指针数值本身没有改变,但它指向的对象已经被释放、又被重新分配,内容已经完全不同。缓存一致性协议关心的是“内存地址上的字节是否发生变化”,它可以在 CAS 期间锁定缓存行、保证比较和交换不被打断。但“A 这个值有没有在更早的时间消失过”这种历史信息,硬件完全不保留。
换句话说,CAS 天然只能回答“这个内存位置现在是不是等于值 V”,没法回答“在我上次读它之后,它有没有经过 V → W → V 的往返”。这正是 ABA 的根本成因。
有人会问,那用 std::atomic 包装一个结构体,把指针和计数器放一起行不行?思路是对的,这也就是经典的 tagged pointer / ABA counter 方案:比较的不是单个指针,而是“指针 + 代际计数器”的组合。
5.3 经典解法与底层代价
在实际工程里,ABA 最常见的解法是给指针加一个单调递增的版本号。你把它打包进一个足够宽的无锁整数里(比如 64 位平台上用低 48 位存指针、高 16 位存计数器),每次修改都同时递增计数器。这样即使指针值被复用了,计数器也几乎不可能回到原值,CAS 比较的就是一个“带历史的复合值”。
实现大致如下:
cpp复制struct TaggedPtr {
Node* ptr;
uint16_t tag;
};
std::atomic<uint64_t> top; // 低 48 位是 ptr,高 16 位是 tag
while (true) {
uint64_t old = top.load(std::memory_order_relaxed);
Node* head = decode_ptr(old);
if (head == nullptr) return nullptr;
Node* next = head->next;
uint64_t desired = encode_ptr(next, get_tag(old) + 1);
if (top.compare_exchange_weak(old, desired)) {
return head;
}
}
代价是什么?一是可用的地址位变少了,二是计数器溢出虽然在 64 位概率极低,但理论上还是存在。此外还可以用 hazard pointer 这类运行时方案来延迟内存释放,尽量避免“旧指针被回收复用”。
从底层视角看,所有 ABA 解法本质上都在做同一件事:扩大 CAS 比较的“指纹宽度”,让值在时间维度上也有区分度。理解这一点之后,再去读各种无锁队列、无锁哈希表的实现,思路会清晰很多。
6. 实战中我总结的判断准则与面试追问
6.1 什么时候真的需要原子操作,什么时候还是用锁
很多项目把 std::atomic 当万能膏药,所有共享变量一律 atomic,这其实不太合适。我自己这些年形成了几条判断准则:
- 简单的计数器、标志位、发布指针之类,用
std::atomic非常合适,开销低、语义清楚。 - 需要保护一段多个读写的复合逻辑(比如“先检查再更新”这种带状态依赖的操作),优先考虑互斥锁,而不是费劲地把多个原子操作拼成无锁算法。
- 多字段之间需要保持一致的场合(比如链表节点指针和节点计数必须同时更新),用锁更符合直觉;强行无锁只会引入整页纸都写不完的复杂推理。
- 只有当你确实测量出锁是热点、而且并发规模大到锁竞争成为瓶颈时,才投入精力去设计无锁结构。
内存序的选择上也有一条实用建议:如果性能敏感,优先使用 acquire/release 而不是默认的 seq_cst,在 x86 上两者差距可能不大,但在 ARM 等弱内存模型上有明显差别。另外,不要在脑子里记“哪个平台是强模型”,跨平台代码一律按 C++ 标准语义来写,否则换平台就是灾难现场。
6.2 面试中最常见的四个底层追问
根据我当年被面试和后来面试别人的经验,和“C++ 原子操作”相关的底层问题几乎绕不开下面四个:
-
std::atomic和volatile有什么区别?
一句话总结:volatile只防编译器优化,不防 CPU 乱序,也不保证缓存一致性;std::atomic同时解决原子性、可见性和顺序性。在 x86 上你可能偶尔蒙混过关,在 ARM 上就直接翻车。 -
为什么 x86 上 seq_cst 的 store 比 load 贵?
因为 x86 的硬件模型允许 StoreLoad 重排。load 天然不会和后续 load/store 重排,所以 load 不需要额外屏障;但 store 为了满足全序要求,需要mfence或xchg来刷空 store buffer。 -
CAS 的底层指令是什么?
x86 上是带LOCK前缀的CMPXCHG,ARM64 上是LDXR/STXR循环,ARMv8.1 LSE 之后可以有单条CASAL之类的指令。能答到这个粒度,面试官基本挑不出毛病。 -
ABA 问题是什么?如何解决?
如果一个问题反复出现在面试题里,通常是它考察的是“你是不是真的理解 CAS 的局限性”。可以先用无锁栈的例子说明什么是 ABA,再补充 tagged pointer 或 hazard pointer 的思路,并指出根本原因是“CAS 只能比较值,不能识别对象的历史生命周期”。
6.3 我在 x86 和 ARM 上踩过的坑
最后分享一个我自己的实际教训。早年在 x86 上写一个无锁 SPSC 队列,release/acquire 和 seq_cst 跑出来的性能差别很小,我就下意识养成了“内存序随便用,反正在 x86 上没差”的习惯。后来把同一份代码移植到 ARM 环境压测,线上才跑两分钟就开始丢数据。查到最后,定位到一个本来应该用 acquire 的 load 被我写成了 relaxed。在 x86 上因为硬件强模型,这个 bug 一直潜伏着;到了弱内存模型的 ARM 上立刻现出原形。
从那以后我养成了一个笨习惯:每次写完无锁相关代码,都会把生成的反汇编快速扫一遍,确认目标平台真的生成了我期望的指令,而不是想当然。这个习惯帮我省了很多次线上事故。如果你也要做跨平台的并发编程,真心建议也这么做——看懂了反汇编里那条 lock 或者那串 ldaxr/stlxr 循环,你对原子操作的理解就真正落地了。
