资讯动态

原子指令与无锁编程:从CAS到内存序的底层原理与实操

发布时间:2026/10/14 4:50:26 来源:尧图企业网站定制
1. 从一条缓存行说起原子指令到底在解决什么问题很多人第一次接触原子指令是在写多线程代码的时候。两个线程同时对同一个计数器做自增跑一万次结果往往不是两万而是一个介于一万和两万之间的随机数。你加锁能解决但锁本身是怎么实现的再往下挖一层就会撞到原子指令这个东西。原子指令属于计算机体系结构中指令系统的一部分它的核心特征是这条指令在执行过程中不会被任何中断、异常或其他处理器核心的操作打断要么全部完成要么完全没发生。这个不可分割的特性是多核时代一切同步机制的硬件基石。没有它操作系统里的自旋锁、信号量、无锁队列、引用计数统统都是空中楼阁。这篇文章适合三类人看一是正在学计算机体系结构、被原子性内存序这些词绕晕的学生二是写并发程序时只会调库、想搞清楚底层到底发生了什么的开发者三是准备面试、被问到CAS和锁的实现原理时答不深的人。我会从指令系统的角度把原子指令的设计动机、典型指令、实现原理、实操验证和踩坑经验完整讲一遍尽量做到看完能自己动手验证。需要先明确一个前提原子指令不是某一款处理器独有的东西它是一个跨越多种指令集架构的通用概念。不同架构给出的指令名字不一样、语义细节有差异但解决的问题是同一个。理解了这一点后面看具体指令就不会觉得零散。2. 指令系统视角下的原子性设计思路2.1 为什么普通指令做不到原子先想清楚一个反直觉的事实在单核时代很多操作天然就是原子的因为一条指令执行期间不会被自己打断。但到了多核时代情况完全变了。两个核心各自执行自己的指令流它们之间没有任何天然的互斥关系。更麻烦的是即使是一条看起来简单的读-改-写操作在硬件层面往往被拆成好几步。以经典的内存值加一为例它至少包含从内存读到寄存器、寄存器加一、写回内存。这三步之间别的核心完全可以插进来读走旧值于是更新就丢了。这就是所谓的竞态条件。所以原子指令的设计目标很明确把读-改-写这一整串动作在硬件层面打包成一个不可分割的整体。实现方式无非两条路——要么在总线上锁住这块内存要么用缓存一致性协议保证独占要么干脆用一条指令完成全部动作。2.2 两条主流技术路线总线锁与缓存锁早期的处理器实现原子操作用的是总线锁。执行原子指令时处理器拉低总线锁定信号独占内存总线其他核心这段时间无法访问内存。这种做法简单粗暴效果确定但代价极大——它锁的是整条总线其他核心哪怕访问的是完全不相干的内存地址也得干等着。核心越多性能塌方越严重。后来主流方案转向了缓存锁。核心思想是如果被操作的数据已经在本核心的缓存行里并且处于独占状态那么直接在缓存层面完成修改不需要动总线。只有当数据不在缓存、或者缓存行被其他核心共享时才需要走缓存一致性协议去抢这个缓存行的独占权。这套机制依赖缓存一致性协议比如常见的MESI及其变体把锁的粒度从整条总线缩小到一个缓存行。注意缓存锁的粒度是缓存行不是变量。这意味着如果两个不相干的变量恰好落在同一个缓存行里对它们的原子操作会互相干扰这就是所谓的伪共享问题。后面会专门讲怎么处理。2.3 原子指令在指令系统中的位置从指令系统的分类看原子指令通常被归入同步与原子操作这一类和普通算术指令、访存指令并列。它的操作数一般指向内存地址语义上保证对该地址的读改写是原子的。不同架构给的指令形态差别很大但可以归为几类典型模式指令模式典型代表语义特点测试并设置Test-and-Set把某位置1并返回旧值用于实现自旋锁交换Swap / XCHG原子交换寄存器与内存的值比较并交换CAS / CMPXCHG比较内存值与期望值相等才写入新值加载链接/条件存储LL/SC先加载并监视条件满足才存储取加/取减Fetch-and-Add原子加一并返回旧值用于计数器这几类里CAS和LL/SC是最重要的两种。CAS是乐观的它假设没人跟我抢抢了我就重试LL/SC是监视的它记录一个监视区间只要区间内没被别人动过存储就成功。两种思路各有拥趸后面细说。3. 核心原子指令逐个拆解与实操要点3.1 比较并交换CAS无锁编程的顶梁柱CAS的语义用伪代码表示最清楚bool CAS(addr, expected, new_value) { if (*addr expected) { *addr new_value; return true; } return false; }整个比较和写入是一个原子动作。它的典型用法是循环重试读当前值算新值尝试CAS失败就重来。这就是无锁计数器、无锁栈、无锁队列的基本套路。CAS最大的坑是ABA问题。假设一个值从A变成B又变回ACAS检查时看到还是A就以为没人动过实际上中间已经变过两轮了。在指针场景下这很危险——内存可能已经被回收又重新分配。解决办法通常是给指针配一个版本号把指针版本号打包成一个更宽的值一起CAS这样即使指针值回到A版本号也变了CAS会失败。实操心得CAS循环一定要有退避策略。高竞争场景下一堆线程疯狂重试会白白烧CPU。可以在失败几次后插入短暂的pause或让出时间片实测能明显降低无效重试。3.2 加载链接与条件存储LL/SC更优雅的替代方案LL/SC把原子操作拆成两条指令LL负责加载并开始监视这个地址SC负责在监视有效的前提下写入。如果LL和SC之间该地址被其他核心写过SC就失败返回需要重试。它比CAS优雅的地方在于天然免疫ABA问题。因为SC检查的是从LL到现在这段时间内有没有人写过而不是值是不是还等于原来那个。哪怕值绕了一圈变回来只要中间被写过SC照样失败。代价是实现复杂。处理器需要维护一个监视状态还要处理各种边界情况比如上下文切换、缓存行被换出、监视区间内发生了中断等。所以有些架构干脆不提供LL/SC只用CAS。3.3 取加与交换简单场景的轻量选择如果只是做计数器不需要比较这个动作那么取加Fetch-and-Add比CAS循环更高效。它一条指令就完成返回旧值并加一不需要重试循环。同理交换指令在实现自旋锁时非常直接拿一个固定值去换锁标志换回来的旧值如果是未锁定说明我抢到了。这类指令的选型逻辑很简单能用单条原子指令搞定的绝不用CAS循环。CAS循环在高竞争下重试次数不可控而单条原子指令的执行时间是确定的。3.4 内存序原子指令绕不开的隐形参数这是最容易被忽略、也最容易出bug的地方。原子指令保证了操作的原子性但不保证内存访问的顺序。现代处理器和编译器都会为了性能重排指令一个核心的写操作在别的核心看来顺序可能和代码里写的不一样。所以原子指令通常要配一个内存序参数常见的有宽松序只保证原子性不保证顺序性能最好获取序保证这条指令之后的读写不会被重排到它前面释放序保证这条指令之前的读写不会被重排到它后面顺序一致最严格前后都不许重排性能最差一个经典组合是释放写 获取读生产者用释放序写入数据再置标志位消费者用获取序读标志位再读数据这样就能保证消费者看到标志位时数据一定已经写好了。用顺序一致当然也能对但性能损失明显。注意不要无脑用最严格的内存序。很多性能问题就是被过度的内存屏障拖出来的。先想清楚这段代码到底需要哪种顺序保证再选对应的内存序。4. 从代码到硬件原子操作的完整实操链路4.1 用高级语言观察原子操作先看一段最直观的对比。普通自增和原子自增在多线程下的表现差异// 普通自增多线程下结果不可预测 counter; // 原子自增结果确定 atomic_fetch_add(counter, 1);编译成汇编后普通自增通常是三条指令load、add、store而原子自增会变成一条带lock前缀的指令在x86上或者一条LL/SC序列在部分RISC架构上。这个lock前缀就是硬件层面保证原子性的关键。你可以自己动手验证写一个多线程自增的小程序分别用普通变量和原子变量各跑十万次多跑几轮。普通变量几乎每轮结果都不一样原子变量每轮都精确等于线程数乘以次数。这个实验花不了十分钟但能让你对原子性的必要性有肌肉记忆。4.2 手写一个自旋锁理解原子指令理解了CAS自旋锁就是水到渠成的事。核心逻辑void lock(atomic_flag *f) { while (atomic_flag_test_and_set(f)) { // 自旋等待 } } void unlock(atomic_flag *f) { atomic_flag_clear(f); }test_and_set就是前面说的测试并设置指令它原子地把标志置位并返回旧值。返回false说明之前是未锁定状态我抢到了返回true说明别人占着继续转。这里有个实操细节自旋等待时最好加pause指令。在x86上就是pause它能让处理器在自旋时降低功耗、减少总线争用还能让超线程的另一个逻辑核获得更多执行资源。不加pause的裸自旋在超线程机器上性能会明显更差。4.3 无锁栈的实现与验证用CAS实现一个无锁栈是检验你是否真正理解原子指令的好练习。核心是push和pop都用CAS循环void push(Node *n) { Node *old_head; do { old_head head; n-next old_head; } while (!CAS(head, old_head, n)); } Node* pop() { Node *old_head; do { old_head head; if (old_head NULL) return NULL; } while (!CAS(head, old_head, old_head-next)); return old_head; }这个实现有个著名的问题pop里读old_head-next和CAS之间别的线程可能已经把old_head弹走并释放了导致访问已释放内存。这就是前面说的ABA问题的变种。工业级实现要么用带版本号的宽CAS要么用内存回收机制比如 hazard pointer配合。自己练手时先跑通基本逻辑再逐步加防护。4.4 参数与选型的量化考量选哪种原子指令、用哪种内存序不能拍脑袋。可以按下面的维度评估评估维度考量点建议竞争程度低竞争用CAS循环问题不大高竞争优先单条原子指令操作复杂度只加一用Fetch-and-Add需要条件判断才用CAS内存序需求只保护单个变量用宽松序跨变量传递数据用释放/获取可移植性CAS几乎所有架构都有LL/SC部分架构缺失ABA风险指针场景风险高加版本号或换LL/SC这张表不是死规矩但能帮你在设计阶段快速收敛方案避免一上来就选最复杂或最保守的做法。5. 常见问题与排查技巧实录5.1 原子操作了为什么还有并发bug这是新手最常问的。答案通常是原子性只保证单次操作不可分割不保证多个原子操作之间的顺序和组合是安全的。比如你先原子读一个标志位再原子读数据这两个原子操作之间别的线程完全可能把数据改掉。要保证组合安全得靠内存序或者锁。排查思路把涉及共享数据的操作列出来问自己这些操作之间别的线程能不能插进来。能插进来就需要额外的同步手段。5.2 性能不升反降的几种典型情况用了原子指令性能反而比加锁还差通常有这几个原因伪共享多个核心频繁原子操作同一个缓存行里的不同变量缓存行在核心间来回弹跳。解决办法是给变量做缓存行对齐填充。过度使用顺序一致每条原子操作都带全屏障流水线被反复打断。按需降级内存序。CAS循环无退避高竞争下疯狂重试。加退避或改用单条原子指令。自旋不带pause白白消耗执行资源。5.3 问题速查表现象可能原因排查方向计数结果偏小竞态非原子操作检查是否用了原子指令偶发崩溃/野指针ABA问题检查指针CAS是否有版本号数据读到旧值内存序不足检查是否缺获取/释放语义多核扩展性差伪共享或总线争用检查缓存行对齐性能不如加锁重试过多或屏障过重降内存序、加退避5.4 几个只有踩过才知道的坑第一个坑以为原子变量声明了就万事大吉。原子类型只保证对该变量的操作是原子的不保证你对它的多次访问构成一个原子事务。复合逻辑该加锁还得加锁。第二个坑在信号处理函数里用原子操作。有些原子指令在特定上下文里不是异步信号安全的可能死锁。这种场景要格外小心。第三个坑跨平台移植时假设指令语义一致。不同架构对内存序的默认强度不一样代码在一台机器上跑得好好的换一台就出问题。写并发代码时内存序要显式声明不要依赖默认值。第四个坑用原子操作保护的数据结构析构时机没处理好。一个线程还在CAS循环里访问某个节点另一个线程已经把它释放了。这类问题往往在压力测试下才暴露平时看不出来。6. 我个人在实际操作中的几点体会原子指令这个东西看文档觉得简单真写起来坑一个接一个。我自己的经验是先把单线程逻辑写对再考虑并发先用最保守的顺序一致跑通再逐步放宽内存序做优化。反过来做出了问题根本不知道是哪一步的锅。另外不要迷信无锁。无锁不等于无等待也不等于更快。低竞争场景下一把简单的自旋锁往往比精心设计的无锁结构更清晰、更好维护性能也不差。原子指令是工具箱里的工具不是信仰。真正该花时间的地方是想清楚数据的所有权、生命周期和访问顺序这些想明白了用锁还是用原子指令答案自然就出来了。如果后续想继续深入可以沿着两条线扩展一条是往硬件走研究缓存一致性协议的具体状态机另一条是往软件走研究内存回收机制怎么和无锁结构配合。这两条线都够挖很久。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑