很多人第一次接触 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:
