上个月做某个模拟并发工程时又踩了同样的坑:两个线程往同一个计数器上各加 50 万次,理论上应该得到 100 万,结果跑完只有 99 万多。看着不像是代码逻辑错误,因为单线程下怎么跑都是对的。真正的原因大家都知道——多线程叠加、计数丢失更新,也就是经典的“非原子操作”问题。但往深里问一句:多核 CPU 究竟是怎么保证某些操作“不可分割”的?一个普通的内存加一操作为什么会被撕裂?CPU 又不认识高级语言,所谓瞬间不可分割,底层靠的是什么?
这篇文章想把这件事彻底拆开讲清楚:从最早的总线锁到现代 CPU 的缓存一致性协议,再到 x86 的原子指令、CAS 实现,以及原子操作和内存屏障之间的关系。适合正在写并发代码、想搞懂无锁编程底层逻辑的人,也适合准备面试时需要把这些概念串起来的人。
1. 为什么“不可分割”会成为一个问题
1.1 一行代码背后的三段执行过程
谈原子操作之前,先搞清楚“不可分割”是针对谁的。写代码的时候,一行counter += 1在高级语言里确实是一句话,但翻译成机器指令之后,远没有一句话那么简单。假设这是一个全局变量,在 x86 平台上它大概率会被编译成类似这样的操作序列:
mov eax, [counter] ; 步骤一:把 counter 的值读入寄存器 add eax, 1 ; 步骤二:在寄存器里加一 mov [counter], eax ; 步骤三:把结果写回内存三段操作,缺一不可。问题就在这个“缺一不可”上:如果有两个线程同时执行这三条指令,它们互相穿插的时序有无数种可能。穿插得当,结果正确;穿插不当,其中一次加法就白白丢失了。
举一个很直观的穿插例子:
| 时间 | 线程 A | 线程 B | counter 实际值 |
|---|---|---|---|
| t1 | 读 counter,得到 50 | 50 | |
| t2 | 读 counter,也得到 50 | 50 | |
| t3 | 加 1 | — | |
| t4 | 写回 51 | 51 | |
| t5 | 加 1 | — | |
| t6 | 写回 51 | 51 |
两次加一,结果只加了 1。这就是典型的“丢失更新”。第一步和第三步之间被人插了一脚,整个过程就被撕开了。如果整个“读-改-写”过程保证中间不会插入其他操作,这个问题就不存在了,这就是“不可分割”或者说“原子性”的朴素定义。
1.2 多核竞争比时间片切换更“防不胜防”
很多人第一次接触并发时,会以为原子性问题主要来自线程调度。操作系统的线程时间片确实会在任意指令边界打断程序,比如一个线程刚执行完mov eax, [counter],时间片就到期了,下个线程在同一块内存上做同样的操作,也会产生丢失。但单核时代,这种被打断的情况还属于“宏观并行、微观串行”:任何时刻只有一个线程在真正运行,所以只要中断处理得当,很多问题在调度层面就能缓解。
多核处理器让事情彻底改观:所有核心是真正的并行,同时执行各自指令流,同一个内存地址可能被无数核心同时读取和写入。在这种局面下,“被打断”不再是唯一威胁,而是一个核心与另一个核心在同一时刻都试图完成“读-改-写”竞争,这种竞争甚至没有先来后到的次序——它们就是同时发生的。
用个生活类比方便理解:单核场景像只有一个收银台,顾客按顺序结账,可能存在中途暂停,但收银台本身不会有两个顾客同时操作;多核场景则是多个收银台同时开,两个顾客在同一瞬间按住同一本账本,各写各的。CPU 要保证账本的每一处修改“看起来是一瞬间写完的”,就得靠硬件机制来仲裁。于是就有了后面这些锁、协议和指令。
2. 最朴素的硬件方案:把总线锁死
2.1 总线锁的构造逻辑
早期 CPU 面对原子性问题时,思路非常简单粗暴:既然原子性要求一段操作中间不能被其他核心干扰,那干脆把通往内存的唯一道路——总线——独占掉。当一个核心要执行“读-改-写”操作时,它会在指令前声明一个特殊的锁信号,硬件上叫LOCK#。这个信号拉低之后,总线仲裁器会暂停其他核心对内存的所有访问授权,直到当前核心的完整原子操作执行完毕。
这是真正意义上的“物理锁”:
- 锁的是所有核心与内存之间的通信通道;
- 在锁周期内,只有持锁的核心能够读取和写入内存;
- 其他核心即便想读取一个毫不相关的地址,也会被硬生生拦住。
总线仲裁器就像一个路口放行员,平时多个车道轮流放行,一旦放行员举了“暂停”牌子,所有车都不能走,唯一在走的车就是拿到LOCK#的那个。
这个方案的好处是彻底。无论操作的是单个字节还是一个跨越多个字节的结构体,只要锁住总线,任何原子性要求都能满足。早期的多核处理器都是这么干的,因为那时候多个处理器插在同一块主板上,共享同一组内存总线,总线锁是最统一、最容易实现的互斥手段。
2.2 总线锁为什么只配做“兜底”
总线锁的代价暴露得也很快。最典型的问题是粒度太粗:锁总线等于让所有核心排队内存访问,哪怕两个核心只是温和地读各自的局部变量,也会被误伤。这好比两个同事只是在各自的本子上写笔记,结果因为共用一张书桌,其中一个人想翻页,另一个人就必须停下笔等着。
实际压测里也很容易看到它的影响。用一个简单的模拟程序,多个线程频繁对同一个变量做原子加一,早期总线锁方案下,线程越多,性能反而越差,因为所有总线事务都串行化,总线很快变成系统瓶颈。而且持锁期间不能做任何其他内存访问,CPU 流水线也会流水空闲,吞吐量自然惨不忍睹。
当时大家已经意识到:必须有一种更细粒度的方案,让“锁”只作用于被修改的那个数据,而不是整个内存空间。于是后来的 CPU 把目光转向了缓存系统。
3. 性能逼迫下的升级:缓存锁与缓存一致性协议
3.1 数据不一定在内存里:每个核心有自己的缓存
要理解现代 CPU 的原子操作,必须先接受一个事实:多核 CPU 上,一个变量并不是物理地待在某一个固定的地方。每个核心都有自己私有的 L1、L2 缓存,日常访问数据时,绝大多数命中缓存,根本不走内存总线。也就是说,同一份counter,可能在核心 A 的 L1 缓存中有一份拷贝,在核心 B 的 L1 缓存中也有另一份拷贝。
既然同一个地址可以存在多份拷贝,问题就出现了:核心 A 改了它的缓存副本,核心 B 还在用旧的副本,那整个系统的语义就分叉了。为了维持一致性,硬件设计出了一整套“内存一致性协议”,其中最著名的就是 MESI 协议。
3.2 MESI 协议:缓存行的四种状态
MESI 用四个状态标记每个缓存行:
| 状态 | 含义 | 说明 |
|---|---|---|
| M(Modified) | 缓存行已修改,且与内存不一致 | 数据只存在于当前核心的缓存中,是唯一最新版本 |
| E(Exclusive) | 缓存行未修改,与内存一致 | 只被当前核心持有,其他核心没有副本 |
| S(Shared) | 缓存行未修改,多个核心可能持有副本 | 所有副本都与内存一致 |
| I(Invalid) | 缓存行已失效 | 该地址的数据需要重新从其他核心或内存获取 |
当核心 A 要修改一个处于 E 或 M 状态的缓存行时,它不需要通知内存,只需要在硬件层面发一个“失效广播”,让其他核对同一地址的缓存行变成 I 状态。这个广播只需覆盖一个地址范围,而不是整个内存总线,所以并发度大幅提升。
原子操作就是借助这套机制得以实现“只锁一个缓存行”:当某个核心要对一个执行“读-改-写”的变量进行原子修改时,它先确保该变量的缓存行处于 E 或 M 状态,然后握紧本地缓存锁,允许自己在本地完成读改写,同时依靠一致性协议让其他核心无法再读到旧副本。整个过程不需要停掉其他核心的全部内存访问,只更新一个局部地址的所有权。这就是“缓存锁”的本质。
如果要找一句大白话总结:总线锁锁的是“整个内存通道”,缓存锁锁的是“一个缓存行的所有权”。后者的代价小得多,完全可以支撑高频的原子操作。
3.3 缓存锁兜不住的两类情况
不过缓存锁不是银弹。有两种情况,CPU 必须回退到总线锁:
- 变量跨越两个缓存行。缓存一致性协议的最小操作单位是缓存行,通常 64 字节。如果一个数据结构的首尾分别落在两个缓存行上,单靠缓存行无效化无法保证“一个地址范围”的原子性,只能锁总线来强制范围一致性。
- 变量没有被任何缓存命中,直接落在内存里。此时连缓存行的所有权仲裁都没办法做。
另外还要提一下工程里常见的“伪共享”问题。假设两个线程分别修改两个独立的变量,但它们恰好被放在同一个缓存行里,那么每次其中任一核心修改自己的变量,都会触发整个缓存行的失效广播,导致另一个核心的变量也被迫失效并重新拉取。表面上没碰同一个地址,实际行为却像在互相争抢。在高并发下,这可能让性能下降一两个数量级。处理这类问题时,经常要给独立的变量填充 padding,让它们分属不同的缓存行。
4. 一条原子指令的拆解:LOCK 前缀、CMPXCHG 与内存对齐
4.1 CPU 指令层的两种原子化方式
到了处理器指令层面,x86 体系提供了两种原子化方式。
第一种:某些指令自带原子性。典型例子是XCHG(交换指令),它从设计之初就被定义为原子操作,自动携带锁行为,不需要额外前缀。这也是自旋锁实现最常见的指令之一——把一个值安全地换进去,整个过程不会被拆开。
第二种:对普通的“读-改-写”指令,通过增加LOCK前缀显式声明原子性。比如:
lock inc dword ptr [counter]这条指令的意图就是“读取 counter、加一、写回 counter”整个过程不可分割。CPU 收到LOCK前缀后,会在执行期间通过总线锁或缓存锁将目标地址“焊死”,不允许多个核心同时操作它。
值得留意的是,这里的“不可分割”是指对目标内存地址的操作过程不可分割,不是说整条指令期间该核心不允许被干扰。指令之间的其他无关操作完全正常,只是目标地址的读改写被硬件保护住了。这也是为什么原子操作在现代 CPU 上代价有限,远小于关中断或者锁总线。
4.2 CMPXCHG:CAS 指令快照
如果说inc、add只是解决“累加”类场景,那么真正把原子操作推向计算化的是比较交换指令CMPXCHG,也就是大家常说的 CAS(Compare-and-Swap)。它做的事情一句话就能说清:
if (目标地址的当前值 == 预期值) { 把目标地址的值写为新值; 返回成功; } else { 不写入任何内容; 返回失败; }整个过程同样不可分割。在 x86 汇编中大概是:
; ecx 保存预期值 ; edx 保存新值 ; [eax] 是目标地址 lock cmpxchg dword ptr [eax], edx现代语言里的std::atomic::compare_exchange_weak/strong、Java 中AtomicInteger.compareAndSet等,最终都落到这类指令上。之所以 CAS 如此重要,是因为它允许“乐观并发”:线程不主动阻塞,先尝试修改,如果发现值已经被别人改了,再决定重试或者放弃,从而实现比锁更细粒度的同步。
4.3 内存对齐:原子性的隐形前提
还有一个特别容易被忽视的细节:内存对齐。
CPU 对内存的访问是按粒度进行的。一个 64 位平台上的 8 字节读操作,如果地址恰好按 8 字节对齐,那么这一次读只涉及一个缓存行,硬件可以轻易保证这个读原子完成;如果地址没有对齐,一次读可能横跨两个缓存行,硬件要做两次总线事务,就没办法在单步内保证一致性了。
对齐规则在不同架构上差别巨大:x86 对未对齐访问还比较宽容,但原子指令如果用在未对齐地址上,要么效率骤降,要么行为受限;在 ARM 等体系结构里,未对齐访问直接就是非法/异常级别的问题。所以现代编程语言在处理原子类型时,编译器会自动为它们选择自然对齐的布局,程序员通常不需要手动干预。但如果你在手工构建数据结构,或者把两个不同宽度的小字段压到同一个联合体里做原子操作,就必须认真审查对齐属性,否则实现的“原子”可能是假的。
5. 从原子指令到上层原语:CAS、ABA 问题与无锁编程
5.1 原子类型并不是魔法:它们只是指令的封装
许多语言提供了原子类型,比如std::atomic<int>、AtomicInteger、ConcurrencyKit相关组件。看起来好像“这个变量自己就是原子的”,实际上底层还是 CPU 指令指令集在支撑。语言库做的事情是:
- 把
load翻译成普通读指令; - 把
store翻译成普通写指令; - 把
fetch_add翻译成lock xadd; - 把
compare_exchange_strong翻译成lock cmpxchg; - 为需要的操作插入内存屏障指令。
所以调试时用汇编级断点去看原子类型的实现,往往能看到非常直白的指令序列,毫无神秘感。了解这一点很有意义:某个原子操作到底是昂贵的还是低成本的,取决于它翻译成了什么指令,以及该指令在目标 CPU 上是走缓存锁还是总线锁。
5.2 CAS 的乐观陷阱与 ABA 问题
CAS 看起来很完美,但它有个非常隐蔽的“认知陷阱”:CAS 只能证明“此刻的值等于预期”,不能证明“这个值中途没被改过”。
想象一个线程在做 CAS 时,值的生命周期发生过这样的故事:
- 初始值 A,线程 X 读取到 A;
- 线程 Y 把值从 A 改成 B,然后业务逻辑运行了一会儿;
- 线程 Y 又把值从 B 改回 A;
- 线程 X 这时做 CAS,预期值是 A,当前值也是 A,于是 CAS 成功,写入新值。
这个场景中,CAS 返回成功,但变量实际上已经被 Y 动过了。这就是广为流传的ABA 问题。在简单的计数场景里,ABA 通常无伤大雅;但在无锁数据结构、指针复用、资源回收场景中,ABA 会直接导致逻辑错误。
举一个具体的模拟案例:某无锁栈里,线程 A 准备弹出栈顶节点,它先读到了“栈顶指针 = 节点 P”,随后线程 B 弹出 P,又压入了一个新节点 Q,最后 Q 恰好复用 P 的内存地址。此时线程 A 做 CAS,预期值是 P 的地址,当前值还是 P 的地址,于是判定“栈没变”,弹出指针 P 指向的那个节点——但此时 P 的内存已经被 Q 覆盖了,整个链表被撕裂。
解决 ABA 问题的常用手段是给每个指针/值附带一个单调递增的版本号,在 CAS 时同时比较值和版本号,确保中间版本没有改变过。许多语言 SDK 里有类似AtomicStampedReference的实现,原理都是这个。
5.3 无锁算法为什么难写:实操体会
从原子指令到无锁队列,还有很长的一段路。真正动手写过一个无锁栈或队列的开发者都会有同感:光保证 CAS 成功还不够,还得考虑内存回收时机、防止 ABA、确保发布的数据对读线程可见,以及处理最棘手的“读线程在持锁时另一个线程已经修改”等边界问题。
我自己在某项目中设计过一个模拟无锁队列,用fetch_add管理队尾索引,用 CAS 管理节点占位。单测一切正常,压测一到高并发就会偶发线程读入 NULL 节点。排查了两天才发现根源不一定是索引算错,而是写线程的 store 与读线程的 load 之间缺少合适的内存顺序语义——读线程提前看到了“位置已占用”的标记,但还没看到节点内的数据。这恰恰说明原子性只是并发编程的底座,内存可见性、顺序一致性是另一半重要问题。
6. 原子操作衍生出的内存屏障与并发语义
6.1 编译器与 CPU 为什么不“老实”执行代码
即使同一个核心上所有指令看起来都有先后顺序,现代 CPU 和编译器也会为了性能对指令进行重排,只要重排后的结果在“单线程视角”下保持一致。举个经典场景:一个线程发布数据,另一个线程消费数据。数据分配、初始化明明写在前,设置ready = true写在后,但另一个线程的 CPU 可能因为缓存未命中而先看到ready = true,再去读数据时,数据还是未初始化版本。
这种事初看非常反直觉,但原因并不复杂:CPU 执行时提前判断指令间是否存在数据依赖,没有依赖的读和写可以被异步完成,缓存也允许延迟写入;编译器也可能调整代码块顺序。结论是:单纯使用原子 load/store,不足以保证多线程代码在多核上正确运行,还需要配合内存屏障或明确的内存顺序语义。
6.2 屏障与 Acquire/Release 语义的分工
内存屏障(Memory Barrier)是硬件提供的一种显式约束:
full barrier执行前,该核心所有的读和写都会在后续指令执行前完成并全局可见;acquire语义:该操作之后的读写不能被重排到这条操作之前,一般配合 load 使用;release语义:该操作之前的读写不能被重排到这条操作之后,一般配合 store 使用。
这两个语义组合起来,正好构成发布-订阅模式的操作规范:写线程先完成数据的所有写入操作,再用一个 release store 置位ready;读线程用 acquire load 读取ready,一旦看到真值,就保证此前写入的数据全部可见。具体到 CPU 指令,x86 下可能是mfence、lfence/sfence或者带锁指令的自动屏障,ARM 下则是dmb ish之类的屏障指令。
这就是为什么现代语言的std::atomic里最重要的概念不是“原子性”,而是“内存序”:memory_order_relaxed、memory_order_acquire、memory_order_release、memory_order_acq_rel、memory_order_seq_cst。它们决定了原子操作不仅携带“不可分割”的属性,还携带“可见性”的规则。很多人只学原子类型不看内存序,等到排查诡异 bug 时才会意识到这一层的存在。
6.3 一线实践中的选型建议
最后聊一点实际干活的心得。很多初学者把“原子操作”捧成银弹,遇事就上CAS、上无锁,结果代码复杂度爆炸,性能反而没提升,因为缓存行争抢和内存屏障的开销比想象中大得多。我的习惯是:
- 普通业务同步优先用现成的锁或并发队列;代码可读性永远是第一位的;
- 需要极低延迟或明确压栈热点时,再做性能剖析,确认锁确实成为瓶颈,再考虑无锁改造;
- 使用原子变量时,时刻问自己三个问题:这个操作真的“不可分割”就行吗?需要哪种内存序?有没有中间状态的 ABA 风险?
- 多线程共享计数、幂等标记这类简单场景,用
fetch_add或带memory_order_relaxed的原子就能高效完成;复杂数据结构还是老老实实锁。
说到底,“多核 CPU 利用缓存锁、缓存一致性协议和专用指令来实现瞬间不可分割”确实是一个精心设计的硬件契约。但这个契约只承诺“不可分割”,并不承诺“整体程序的顺序”。理解这一点,才能真正驾驭原子操作,而不是反过来被各种隐蔽的并发 Bug 折磨。