ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CAS与ABA问题破解:无锁编程的版本号与延迟回收方案

CAS与ABA问题破解:无锁编程的版本号与延迟回收方案 我到现在还记得那次线上事故排查一个压测中的无锁队列跑了不到两小时开始偶发节点丢失日志里怎么都找不到规律。最折磨人的是所有 CAS 操作的结果都显示成功程序却依然给出错误的状态。那是我第一次真正见识到 ABA 问题的杀伤力——表面上看起来“数据没变”实际上中间已经经历了翻天覆地的变化。也正是那段排查经历让我对无锁编程的底层语义有了完全不同的理解。这篇文章我想把 CAS、ABA 问题以及破解方案一次讲透配合 C 和 Java 两个方向的代码示例。不管你是刚接触无锁编程的新手还是已经在线上踩过坑的资深工程师都值得花十分钟读完下面这部分内容。因为这类问题一旦发生排查成本通常远超你的预期而且它不会以“报错”的方式提醒你——它只会在你的数据结构里悄悄撕开一道口子。1. 无锁编程的基石从“锁的代价”到 CAS1.1 锁到底慢在哪在介绍 CAS 之前先理解为什么我们需要 CAS。传统多线程编程用锁保护共享变量临界区同时只能进一个线程。这个做法简单可靠代价却藏在看不见的地方。线程在获取锁失败后会被操作系统挂起进入休眠状态并让出 CPU等到锁被释放系统再找到它、唤醒它、重新调度它。一次完整的线程切换往往要消耗几十微秒而一条原子指令在硬件上只需要几个纳秒。如果临界区本身的代码只有几十纳秒那锁的开销就是临界区执行时间的一千倍以上。更麻烦的是锁竞争越激烈上下文切换越频繁CPU 缓存命中率下降整体性能会进一步恶化。还有一个不那么直观的问题锁的粒度很难控制。你用一把大锁保护整个数据结构两个线程明明操作的是不同节点也要互相等待你用细粒度锁死锁和顺序问题又来了。无锁编程就是为了绕开这些问题产生的——它不用操作系统挂起线程而是通过原子操作让多个线程“尝试”更新数据成功就继续失败就重试。高并发场景下这种忙等策略反而比线程切换更高效。1.2 CAS 的硬件原语与内存语义CAS 的全称是 Compare And Swap硬件上就是一条指令。它的语义可以写成这样bool compare_and_swap(void* ptr, void* expect, void* update) { if (*ptr expect) { *ptr update; return true; } return false; }关键区别在于“读取当前值、判断是否相等、写入新值”这三步在硬件层面是原子的中间不存在任何让其他线程插入的空隙。这不是编译器优化能做到的而是 CPU 指令集提供的原生能力。C 里最常见的写法是std::atomicint counter{0}; int current counter.load(); if (counter.compare_exchange_strong(current, current 1)) { // 更新成功 }注意这里current是引用语义如果 CAS 失败current会被改写为最新的实际值所以可以直接放进循环里重试。Java 里则更直白一些AtomicInteger counter new AtomicInteger(0); int current counter.get(); if (counter.compareAndSet(current, current 1)) { // 更新成功 }除了 CAS还有 Test-And-Set、Fetch-And-Add、Compare-And-Exchange 等原子原语。它们的共性都是单条原子指令内完成“检查 修改”这是无锁编程能成立的前提。1.3 CAS 最适合解决的场景CAS 擅长处理“读-改-写”类型的操作典型场景包括计数器与统计值比如线程数、库存余量、点击量标志位翻转不可变状态机的推进、单次初始化引用计数智能指针、GC 辅助数据结构头节点无锁栈、无锁队列的入队/出队用一个贴近生活的类比CAS 就像一个门卫他只会做一件事——如果房间里站着“你认为的那个人”就让他进去否则就什么都不动。并且“看清是谁 放行”是同一个动作中间没有任何间隙。这个类比能解释后文的所有问题请记住它。2. ABA 问题为什么“值没变”不等于“状态没变”2.1 从理论定义到第一个崩溃瞬间ABA 问题的定义一句话就能说清一个变量从 A 变成 B再从 B 变回 A。此时 CAS 检查发现“值还是 A”就判定操作成功但实际上中间已经发生过其他线程的插入操作。问题在于CAS 只比较值不比较时间线。值相等不代表状态一致尤其当这个值是一个指针、索引、引用句柄时中间的 B 状态可能已经彻底改变了数据的结构。最经典的例子也是我第一次真正理解它的场景是 Treiber 无锁栈。2.2 Treiber 无锁栈的现场还原Treiber 栈是教科书级的无锁数据结构入栈和出栈都只用 CAS 操作栈顶指针top。你可以先想象一个初始状态Top - A - B - C栈顶是 AA 的 next 是 BB 的 next 是 C。线程 T1 准备执行 pop 操作它先读到top A并且保存了A-next B。就在它准备 CAS 的瞬间被操作系统切走了。这是整个事故的起点——无锁编程里所有的崩溃都发生在“读取了旧状态但还没完成更新”这个窗口。线程 T2 接管后开始操作T2 执行 pop弹出 A栈顶变为 BT2 继续执行 pop弹出 B栈顶变为 CT2 执行 push(A)把 A 又压回栈顶栈顶重新变为 A并且A-next被改成了NULL。此时栈的状态是Top - A - NULL。原来的 B、C 节点已经不在链表中了。T1 终于被唤醒它在 CAS 中期望top A。比较后发现 top 确实还是 A于是 CAS 成功它把top更新为A-next也就是它记忆里的 B。但问题来了这个 B 已经不是当初栈结构里那个 B 了。它可能已经被释放回内存池甚至被复用作其他对象。T1 更新后的栈顶指向一块早已不属于当前栈的内存整个链路直接断裂。站在内存层面看top 变量从 A 变成 B 再变回 AT1 从头到尾只看到了 A——这就是 ABA 名字的由来。2.3 不止无锁栈会炸哪些场景容易踩坑总结下来以下场景是 ABA 的高发地带无锁队列head/tail 指针的 CAS 同样管不住节点地址重用队列里的“幽灵元素”经常是 ABA 的杰作。对象池与内存复用拉取对象、归还对象的过程如果依赖指针 CAS地址被归还后再被分配到别处就会造成“A 地址指向了身份完全不同的对象”。引用计数两个线程对同一个计数做 CAS看似计数相等但中间对象可能已经进入了回收流程。计数相等但生命周期状态不等就会误释放正在使用的对象。自旋锁用 CAS 实现的自旋锁也有 ABA 的影子。一个线程持锁后阻塞另一个线程抢锁、释放锁锁状态看起来又变回“未持有”。如果前者醒来后只凭 CAS 判定“锁可用”但它的锁其实已经丢了行为就会出错。缓存索引很多系统用自增 ID 做缓存键当 ID 回绕或被复用后用 CAS 比较旧 ID 也会踩坑。核心结论是只要 CAS 比较的“值”存在被重用、回绕、复用的可能你就该考虑 ABA。尤其是引用/指针型变量几乎必然中招——因为堆内存上的 malloc 分配和释放非常频繁操作系统极有可能把一个刚释放的内存块重新分配给后续的分配请求。这意味着即使一个指针的“数值”一模一样两次取值之间它指向的对象可能已经换了主人。ABA 在指针语义下几乎是环境条件不是偶然意外。3. 破解之道从版本号到延迟回收3.1 方案一版本号/标记位——CAS 加一维时间轴最直观的思路是给目标值配一个版本号Stamp。每次修改值可以回到原点但版本号只增不减。CAS 比较时同时比较“值 版本号”只有两者都相等才算成功。Java 标准库里已经有现成的工具AtomicStampedReferenceV。它内部维护一个PairV, IntegercompareAndSet同时验证引用和版本号。使用示例AtomicStampedReferenceNode top new AtomicStampedReference(head, 0); int[] stampHolder new int[1]; Node current top.get(stampHolder); Node next current.next; if (top.compareAndSet(current, next, stampHolder[0], stampHolder[0] 1)) { // 成功 }这里的关键等式是引用相等 并且 版本号相等才能完成替换版本号在每次成功修改时递增。这样即使节点地址从 A 变回 A版本号也已经从n变成了n1旧线程的 CAS 就会失败然后重试读最新状态。对于只需要“标记是否变化过”的场景AtomicMarkableReferenceV更省内存它内部只有一个布尔标记位每次修改时翻转它。使用版本号要注意一个问题版本号本身的溢出。32 位 int 版本号如果被高频操作频繁递增极端条件下会回到原点ABA 死灰复燃。虽然现实中很难碰到但严谨的系统会使用 64 位版本号或者在单调递增的分配器里兜底。3.2 方案二Tagged Pointer——把地址和版本号塞进一个原子字版本号方案在 Java 里实现很简单因为语言封装好了。但在 C/C 里把一个指针和一个版本号放进结构体再对结构体做 CAS会碰上一个现实问题大多 CPU 的 CAS 只能操作一个机器字通常是 8 字节没法原子地同时比较“指针 另一个独立变量”。解决思路有两种本质都是“想办法压缩到 8 字节以内”。第一种做法利用内存对齐。现代 CPU 上通过malloc或者new分配的对象地址通常按 8 字节对齐也就是地址的低 3 位永远是 0。这 3 个位可以腾出来放标记或版本号。操作时先把普通指针编码成一个包含 tag 的uintptr_tCAS 成功后再把 tag 清除恢复真实指针constexpr uintptr_t TAG_MASK 0x7; // 低 3 位做 tag Node* get_node(uintptr_t encoded) { return reinterpret_castNode*(encoded ~TAG_MASK); } uintptr_t get_tag(uintptr_t encoded) { return encoded TAG_MASK; }每次成功更新top时都把版本号递增并写进新编码值里uintptr_t encoded top.load(); uintptr_t old_tag encoded TAG_MASK; Node* next_node get_node(encoded)-next; uintptr_t new_encoded (reinterpret_castuintptr_t(next_node) ~TAG_MASK) | ((old_tag 1) TAG_MASK); top.compare_exchange_strong(encoded, new_encoded);即使next_node正好等于旧节点地址比如 A 被压回栈顶因为新编码里的 tag 已经变了旧线程拿旧编码去做 CAS 依然会失败。这就是 Tagged Pointer 的精髓同一个地址因为携带的版本号不同被视为不同的值。第二种做法更通用在 x86-64 平台用户态指针实际只使用低 48 位现在硬件逐步扩展到 57 位但用户态仍有余量可以把高 16 位放版本号低 48 位放指针。但这种方式跟平台强绑定换架构就要改代码。除非你在写一次性配套的底层库否则我更推荐低 3 位 tag 方案——它只依赖于“8 字节对齐”这一普遍事实跨平台移植性更好。注意用低位 tag 方案前务必确认对象的对齐方式。如果你用了#pragma pack(1)或者自行分配了未对齐的内存低 3 位可能非零tag 方案不再适用。在 C17 里可以写个静态断言拦一道static_assert(alignof(Node) 8)。3.3 方案三别让 ABA 有发生的土壤——延迟回收与 RCU版本号与 Tagged Pointer 是“检测到变化就失败”。还有另一种更巧妙的思路让旧值不可能被复用。只要旧地址永远不会被重新分配给新对象ABA 就无从谈起。GC 语言天然占有这个优势。Java 的AtomicReference虽然也做 CAS但 JVM 的垃圾回收器不会在对象存活期间把刚弹出的节点地址重新分配给新对象。因此在 Java 中纯粹的指针型 ABA 很难发生真正需要小心的是“值语义”字段的 ABA。C/C 世界里则要靠自己实现延迟回收。常见方案包括RCURead-Copy-Update读操作不加锁直接读旧数据写操作复制一份副本修改完再发布新版本旧版本节点不立刻释放等到所有读者离开临界区后才回收。Hazard Pointer每个线程在自己的线程局部区记录“我当前正在读哪个节点”。写站在释放节点前先看有没有线程记录了该地址有就延迟释放。引用计数在 CAS 更新前先对节点引用计数加 1确保对象不会被并发释放操作完成后再减计数。实现简单但对计数本身的 CAS 又可能引入新的 ABA需要结合版本号使用。这三种方案的共同点是用“内存安全”换“不检测 ABA”。在绝大多数无锁数据结构设计里它才是更本质的解法。3.4 四种方案横评方案原理实现成本性能开销适用场景版本号 / Stamp值 版本号同时比较低Java 有现成中等值语义、引用语义都能用通用性最强Tagged Pointer地址低位塞版本较高需要对齐检查低C/C 指针型无锁结构RCU / 延迟回收旧节点晚点释放高读快写慢读多写少的无锁容器引用计数 CAS先增计数再 CAS中中对象生命周期管理从实际项目角度Java 里优先用AtomicStampedReferenceC 里优先用低 3 位 tag除非你的对象分配器无法保证 8 字节对齐。4. 实战避坑从方案选型到线上排查4.1 如何根据数据类型选择防 ABA 方案先对数据类型做一个粗粒度分类纯整数自增/减计数器一般不必防 ABA因为业务通常只关心最终值中间过程不可观测。但如果你用 CAS 做“库存扣减再补回”这类补偿逻辑就需要版本号。判断标准是业务是否关心“被其他线程读到的中间状态”。引用/指针语义几乎必须防 ABA。用 Tagged Pointer 最省事但要保证对齐。混合结构比如无锁队列的完整节点用版本号方案做整体 CAS 成本较高不如在关键的 top/head/tail 指针上加 tag。实际项目中我最推荐的策略是“先防指针型 ABA再看业务是否需要对值型防 ABA”。不要一上来给所有 CAS 都加版本号那会把一个简单计数器变成一台昂贵的摩擦机。4.2 C Tagged Pointer 的完整实现细节除了核心思路还有几个细节值得说深一点。第一对齐校验。在代码里加编译期断言而不是运行时报错static_assert(alignof(Node) 8, Node must be at least 8-byte aligned);第二解引用前必须清 tag。我曾经见过一个线上 bugtag 方案上线后进程频繁崩溃GDB 一查全是非法地址访问原因是拿到编码后的值后直接reinterpret_castNode*(encoded)-next去读忘了把低 3 位清零。这短短一行就是事故的全部。第三位宽与回绕。3 位 tag 的取值空间是 0 到 7如果一个地址在极短时间内被连续复用 8 次tag 会回绕到原值ABA 会死灰复燃。在绝大多数业务下 8 次足够但如果你在设计一个极度追求极限的底层组件就得多留几个位比如利用 16 字节对齐时的低 4 位或者直接走高 16 位方案。第四CAS 失败后绝不能盲目重试。使用 tag 戳破一次假 CAS 后必须重新 load top 的最新值重新提取 next 指针再发起新 CAS——每次失败都要刷新全部状态。很多人栽在这上面循环写对了但循环体里用的还是旧的current和next于是陷入活锁。4.3 Java 无锁栈的完整示例写一个防 ABA 的 Treiber 栈方便对照public class LockFreeStackT { private static class NodeT { final T value; NodeT next; Node(T value) { this.value value; } } private final AtomicStampedReferenceNodeT top new AtomicStampedReference(null, 0); public void push(T value) { NodeT newNode new Node(value); int[] stamp new int[1]; NodeT current; while (true) { current top.get(stamp); newNode.next current; if (top.compareAndSet(current, newNode, stamp[0], stamp[0] 1)) { return; } } } public T pop() { int[] stamp new int[1]; NodeT current; NodeT next; while (true) { current top.get(stamp); if (current null) return null; next current.next; if (top.compareAndSet(current, next, stamp[0], stamp[0] 1)) { return current.value; } } } }这段代码跟经典 Treiber 栈唯一的差别就是多了一个 stamp。在 pop 失败后循环会重新读取最新的 top 和 stamp而不是拿旧的再试一次——这本身就是防止 ABA 死循环的关键。4.4 一次线上 ABA 事故排查实录我处理过的一起事故现象是压测环境下无锁 FIFO 队列偶发“丢节点”。队列的 head 和 tail 都用 CAS 更新出队人数和入队人数从日志看偶尔不等而且无法稳定复现。排查走了三条路第一步看日志和统计确认不是算法逻辑错乱而是某个 CAS 假成功第二步开启线程调度记录发现丢节点的时间点往往出现在“一个线程从睡眠里被唤醒、立即执行出队”的窗口第三步用 GDB 断在 CAS 附近打印每次 CAS 前后的节点地址发现有一组前后完全相同的地址——节点指针在队列里被弹出又压回地址没有变化但 next 指针已经变了。这正是 ABA。修复方式选用 Tagged Pointer因为当时已经在 C 场景下低 3 位 tag 改动最小也没有引入新的分配策略。压测 12 小时后不再出现丢失问题闭环。这件事最大的教训是无锁代码的正确性不能靠“看”要靠压力测试和工具。除了 TSan、GDB我还会用 Java 的 Loom 或者形式化建模TLA来辅助验证尤其是设计新模块时模型检查能提前发现一批隐蔽的并发 bug。5. 常见误区与高频面试考点5.1 误区一ABA 问题只在指针类型中出现这不是事实值类型也时有发生。典型的例子是账户余额扣减。线程 A 读取余额 100准备扣到 90线程 B 先扣到 85又通过某种补偿逻辑补回 100。此时线程 A 的 CAS(100 - 90) 必然成功但它实际扣的是一个“后来才涨回去”的余额中间那笔交易的历史信息已经丢失。这种场景是否需要防取决于业务语义。如果只要求“当前余额最终正确”ABA 带来的“多扣一次/少扣一次”可能还能接受。但支付网关那种强一致场景就不能有任何侥幸。5.2 误区二用 volatile 或 synchronized 就能避开volatile只能保证可见性不提供原子性更没法检测中间变化。synchronized虽然保证互斥但你把 CAS 换成synchronized后就不再是无锁编程了性能逻辑也变了。ABA 是 CAS 语义的自带属性避不开它只能用更强的语义版本号或换实现锁。5.3 误区三stamp 的初始值随便传一个就行AtomicStampedReference构造时传入的 stamp 代表状态的“出生版本”。如果业务里用 -1 表示“未知”而初始 stamp 传了 0第一条 CAS 和系统初始化之间就可能出现语义错位。规范做法是把 stamp 当作单调递增的序列号来用不要赋予它业务含义。还要警惕递增溢出。int 最大值约 21 亿大多数业务一亿年也跑不到但如果你的系统里有“高频率循环复用同一地址的节点池”几年内是有可能戳到边界的。真遇到这种边界最稳妥的兜底是加一个全局重置逻辑或直接切换成 64 位计数器。5.4 高频面试题手写防 ABA 的无锁栈这道题在面试中出现频率极高。面试官的潜台词是你能不能识别 CAS 的假成功并且用最少代码解决。参考思路如下第一步定义节点顶层变量就是AtomicStampedReferenceNode第二步push 时用 stamp 包装新节点第三步pop 时用 stamp 记录当前版本CAS 时同时更新版本第四步失败后重新读取 top 和 stamp再循环。追问点通常是如何在 32 位环境下实现同样的防 ABA这个答案并无定式常见拆法是“用两个 32 位原子组合成一个逻辑原子变量”但两个 32 位之间还有空隙并发下并不完全安全。更稳妥的答案是用锁保护。这时候面试官更想听的是无锁是手段不是目的。正确性和可维护性才是第一位的。我在实际开发中的体会是ABA 问题不是“nice-to-have”的优化点而是无锁编程必需的一课。所有基于 CAS 的高并发组件架构设计阶段就要确立两件事这个值会被复用吗中间状态会被其他线程观测到吗只要有一个答案是肯定的就老老实实加上版本号或延迟回收不要心存侥幸。最后再分享一个排查小技巧如果你的无锁结构在压测时偶现异常不要立刻怀疑 CPU 或编译器先写一个 5 分钟内能跑出结果的高并发循环测试把 CAS 成功次数与总操作次数同时打出来。如果成功次数远大于预期就说明有假成功——这时候 80% 的注意力可以放在“变量是否被复用”上。这个经验帮我省下很多时间也希望它能让你少走一次弯路。
返回列表