上个月做某个模拟并发工程时又踩了同样的坑两个线程往同一个计数器上各加 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线程 Bcounter 实际值t1读 counter得到 5050t2读 counter也得到 5050t3加 1—t4写回 5151t5加 1—t6写回 5151两次加一结果只加了 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 用四个状态标记每个缓存行状态含义说明MModified缓存行已修改且与内存不一致数据只存在于当前核心的缓存中是唯一最新版本EExclusive缓存行未修改与内存一致只被当前核心持有其他核心没有副本SShared缓存行未修改多个核心可能持有副本所有副本都与内存一致IInvalid缓存行已失效该地址的数据需要重新从其他核心或内存获取当核心 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 CMPXCHGCAS 指令快照如果说inc、add只是解决“累加”类场景那么真正把原子操作推向计算化的是比较交换指令CMPXCHG也就是大家常说的 CASCompare-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::atomicint、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 折磨。