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,又变回A,CAS检查时看到还是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. 我个人在实际操作中的几点体会
原子指令这个东西,看文档觉得简单,真写起来坑一个接一个。我自己的经验是:先把单线程逻辑写对,再考虑并发;先用最保守的顺序一致跑通,再逐步放宽内存序做优化。反过来做,出了问题根本不知道是哪一步的锅。
另外,不要迷信无锁。无锁不等于无等待,也不等于更快。低竞争场景下,一把简单的自旋锁往往比精心设计的无锁结构更清晰、更好维护,性能也不差。原子指令是工具箱里的工具,不是信仰。真正该花时间的地方,是想清楚数据的所有权、生命周期和访问顺序,这些想明白了,用锁还是用原子指令,答案自然就出来了。
如果后续想继续深入,可以沿着两条线扩展:一条是往硬件走,研究缓存一致性协议的具体状态机;另一条是往软件走,研究内存回收机制怎么和无锁结构配合。这两条线都够挖很久。