☰
CAS与ABA问题深度解析:无锁编程的隐形陷阱与破解方案
2026/10/5 7:40:25 网站建设 项目流程

写并发队列的时候,我最怕的不是锁不够快,而是代码里藏着一个“你看不见它、它却能让你线上事故”的幽灵。这个幽灵就是CAS操作里的ABA问题。很多刚接触无锁编程的工程师,看完十来篇讲AQS、讲AtomicInteger的文章,就以为CAS是万能的神器——指令够轻、冲突够少、性能够快,结果一上线,某些数据莫名其妙“丢”了,某些节点“幽灵式”消失,排查三天才反应过来:这是ABA问题在作祟。今天这篇,我就把CAS与ABA问题彻彻底底讲透,从底层原理到C++代码演示,从经典坑位到实际工程中的破解方案,一次性讲到位。不管你是在写高并发缓存、自研无锁队列,还是面试被连环追问“ABA解决方案”,这篇文章都能让你少走大把弯路。

1. 无锁编程的核心机制:先搞懂CAS到底是啥

1.1 CAS之所以快,是因为它把“比较”和“交换”焊死成了一个原子操作

CAS全称是Compare-And-Swap,直译过来就是“比较并交换”。它的逻辑极其简单:某个内存地址里的值如果是A,那我就把它改成B,如果没有成功就什么都不做。整个过程是原子的,中间没有任何线程能插一脚。

代码长这样:

bool compare_and_swap(int* addr, int expected, int new_value) { if (*addr == expected) { *addr = new_value; return true; } return false; }

但真实硬件环境里,这个逻辑不是“先读、再比、再写”三步拼起来的,而是由一条CPU指令完成的,比如x86上的cmpxchg。这意味着,比较那一瞬间到写入那一瞬间,是物理上不可分割的。其他线程不可能在这一段“真空期”里修改数据。

CAS恰恰是“无锁编程”的地基。换句话说,无锁指的不是没有同步,而是不再用操作系统级的锁(比如mutex),改用CPU级别的原子指令来保证安全。

那CAS快在哪里?快在“没有线程阻塞”。用锁的时候,如果一个线程拿不到锁,操作系统可能把它挂起,后续还可能产生上下文切换和缓存颠簸,代价相当高。CAS则不然,它本质上就是一条硬件指令,处理器自己能搞定,绝不会把线程哄睡。所以很多追求极端性能的系统,比如Java里的ConcurrentHashMap、Netty里的无锁内存池、编译器里的Fine-Grained队列,底层都昂首挺胸地站着CAS这种“最简原子”的抽象。

1.2 CAS与自旋锁:死磕到底的背后是“乐观并发”

我们通常把mutex叫悲观锁,因为它默认一上来就要挡住别人。CAS配合循环使用,就演变成了所谓的乐观锁思路——我默认不会和别人冲突,先试一下,失败了再重试,重试过程就是“自旋”。

一套典型自旋锁的伪代码:

while (!compare_and_swap(&lock, 0, 1)) { // 忙等,直到CAS成功 }

这个里面有没有问题?有。如果持有锁的线程在临界区内迟迟不出来,其他线程会一直自旋空烧CPU。虽然对短临界区来说“空烧”往往比“挂起线程”要划算,但你要真在长时间持锁的场景里这么搞,分分钟把CPU打满,也是无锁编程里一个十分常见的性能暗坑,这节先不展开。

1.3 无锁编程的核心价值:性能、可伸缩性、不轻易死锁

无锁编程为什么值得投入?我总结三个真正站得住脚的点:

  • 减少上下文切换和内核态陷阱:无锁情况下,多数同步是在用户态完成的,几乎没有陷入内核的损耗。
  • 不被锁死:mutex持有期间如果线程被异常终止,可能出现锁永远不被释放;CAS不存在持有锁一说,也就不存在这类“永久死锁”。
  • 高并发下扩展性更强:锁竞争激烈时,线程越多,切换到锁上的代价越大;无锁结构通常在线程多了之后依然能维持较高吞吐,这也是“多核心时代”大厂依然执着于无锁化的根本原因。

不过好处讲到这,就得马上泼盆冷水:这些好处全都是建立在你把并发逻辑梳理清楚的前提上。CAS一旦被错误使用,会掉进一个比死锁更隐蔽的坑——ABA问题。

2. ABA问题:到底怎么冒出来的,又为什么会血溅现场

2.1 一个最简单的复现路径:值绕了一圈又回来了

所谓ABA问题,说白了就是:

  • 线程1读到共享变量值为A;
  • 线程1被调度出去(或卡住一段时间);
  • 线程2趁机把值从A改成B,再改回A;
  • 线程1终于重新跑起来,拿旧值A去做CAS比较,发现内存里还是A,CAS成功了。

表面看来,线程1的“比较”没被欺骗,但真实世界已经翻天覆地。现实里,这就好比你想用钥匙打开自己家大门,走开一阵后邻居把你家门拆了、换成别人的门,在你回来之前又把你家门安回去。你在没有察觉的情况下顺利开了锁,却压根不知道这扇门已经不是原来的门了。

对于只关心“值是不是等于A”的简单场景,ABA好像也没造成大问题——毕竟最终值仍为A。但如果这个“值时附带的语义”变了,问题就大了。

2.2 最经典的坑位:无锁栈的“栈顶指针”很容易触发ABA

拿C++单链表栈举例。栈顶指针top指向一个节点,POP操作思路很直接:

  1. 记下当前top;
  2. 把下一个节点当作新top;
  3. CAS更新top,从旧节点换成下一个节点。

看起来天衣无缝,直到ABA出现:

  • 线程1要POP,记下top = Node(A),然后线程1被打断;
  • 线程2 POP,取走Node(A),此刻top = Node(B);
  • 线程2继续POP,取走Node(B),此刻top = Node(C);
  • 线程2又PUSH一个Node(A)(或者Node(A)被某个线程重新挂回栈顶);
  • 此时top又等于Node(A)的地址——注意,是同一个内存地址还是相同值的对象都说不定;
  • 线程1醒来,CAS比较top == Node(A)成立,于是把它更新成了Node(B)。

可问题是,此刻栈顶的Node(A)可能已经被内存回收,或者它内部的next已经被线程2改得不知去向。线程1以为自己在执行一个正常POP,实际上在操作一个早已不属于栈结构的垃圾对象,轻则数据错误,重则栈结构全灭。

2.3 为什么这能引发最可怕的内存损坏

ABA问题最疼的地方不在“值不对”,而在“引用悬空”。无锁编程中,很多东西是“先摘链再处理”的,当你CAS成功后,你可能紧接着就拿那个旧节点来释放内存、修改内部字段,甚至访问它的next指针。可这个节点可能在ABA期间被另一个线程删掉并归还给系统。

一旦发生这种情况,你的线程就是在读写一块已经释放的内存。就算用智能指针、用引用计数,也难以完全兜住无锁结构中这种“逻辑上失去一致性”的引用。所以说ABA不是一种简单的“竞态条件”,它是通往内存损坏的重灾区。

3. 实操演示:用C++代码把ABA问题放大到能看见

理论讲再多,都不如现场写一段能跑、能炸、能观测的代码。我在本地搭程序复盘时,特意造了一个无锁栈,用ABA问题把数据“变丢”给大家看。

3.1 实验设计:一个会出事的无锁栈

先定义一个简单节点结构,然后写一个只包含push和pop的无锁栈。注意,我这个版本故意不引入任何安全防护,以此暴露ABA问题。

#include <atomic> #include <iostream> #include <thread> #include <vector> struct Node { int val; Node* next; }; class SimpleLockFreeStack { private: std::atomic<Node*> top{nullptr}; public: void push(Node* node) { node->next = top.load(); while (!top.compare_exchange_weak(node->next, node)) { // 失败时 node->next 会被更新为最新 top } } Node* pop() { Node* old_top = top.load(); while (old_top && !top.compare_exchange_weak(old_top, old_top->next)) { // 失败时 old_top 会被更新为最新 top } return old_top; } };

眼尖的朋友已经看出问题了:pop里拿old_top->next的时候,旧栈顶可能已经被别人改了。ABA最经典的操作就这么埋下了。

3.2 碰撞实验:两个线程把一个节点反复移出再移回

我需要定义一个“攻击脚本”,让一个节点被弹出栈,随后又被无条件推回栈,同时主干线程正在执行POP。

完整测试代码:

int main() { SimpleLockFreeStack stack; Node nodes[3]; nodes[0].val = 1; nodes[0].next = nullptr; nodes[1].val = 2; nodes[1].next = nullptr; nodes[2].val = 3; nodes[2].next = nullptr; stack.push(&nodes[2]); stack.push(&nodes[1]); stack.push(&nodes[0]); // 栈:node0 -> node1 -> node2 // 线程A:尝试把栈顶换掉 std::thread threadA([&]() { Node* res = stack.pop(); if (res) { std::cout << "ThreadA popped: " << res->val << std::endl; } }); // 线程B:把 node0 pop 出来,再 push 回去,制造ABA std::thread threadB([&]() { Node* res = stack.pop(); if (res) { std::cout << "ThreadB popped: " << res->val << std::endl; // 等A读到了旧top std::this_thread::sleep_for(std::chrono::milliseconds(10)); stack.push(res); } }); threadA.join(); threadB.join(); Node* cur = stack.top.load(); while (cur) { std::cout << "Remaining stack: " << cur->val << std::endl; cur = cur->next; } return 0; }

我在多台机器上跑过这段脚本,很多次你都能看到离奇输出:线程A和线程B都声称按POP取到了node0,而剩余栈已经变成只有node2甚至空栈,node1凭空消失。原因正是线程A在CAS比较时,栈顶已经被线程B折腾回node0,线程A成功把栈顶改成了它记忆中node0->next那个指针,而那个指针早已被B线程顺手改成了别的节点或nullptr。数据结构直接错乱。

3.3 这个代码告诉我们什么:普通CAS根本无法分辨“同一地址”和“同一值”

通过这段实验,我们要内化一个认知:CAS只认“当下内存值和预期值一不一样”,完全不关心中间曾发生多少次变化。ABA的本质就是在“变化过程被完全掩埋”的前提下,让CAS误判“状态未变”。所以,任何依靠单个宽度标签来做无锁校验的场景,都可能在极高并发和大量重用时炸出问题。

4. 破解之法一:版本号计数器或标记位——最常见的工业解法

4.1 基本原理:再不起眼的备份,也要绑个时间戳

ABA之所以成功,是因为它完美隐藏了“中间状态”。那解法就是让“状态变化”本身能被看到。手段也简单:凡是CAS要比较的数据,附带一个只会递增、从不回退的版本号。

在C++里我可以把“指针+版本号”打包成一个足够宽的无锁变量。好处是,即便指针值绕一圈回到同一个节点,只要版本号没变就说明没发生过变化;哪怕指针被复用,只要版本号+1了,CAS就会拒绝更新。

Java里面直接把这事标准化了,就是AtomicStampedReference。它内部把引用和整型标记绑在同一个变量上:

  • CAS前后对比的不再只是一个引用,而是“引用+版本”
  • 每次操作都会把版本号+1
  • 若版本号不一致,CAS就失败

C++没有内置这个包装类,但我们可以借助std::atomic配合一个足够大的结构体来自行实现。

4.2 用C++实现一个带版本号的指针包装

常规做法是搞一个双机器字(double-word)CAS,或者直接把指针+计数压缩进uintptr_t。不过最稳、最可移植的办法是自定义一个结构体,然后用支持16字节原子比较交换的C++条件编译来加载硬件指令。

struct Node { int val; std::atomic<Node*> next{nullptr}; }; struct TaggedPointer { Node* ptr; uintptr_t tag; }; class SafeStack { private: std::atomic<TaggedPointer> top; public: SafeStack() : top({nullptr, 0}) {} void push(Node* node) { TaggedPointer old_top = top.load(); TaggedPointer new_top; do { node->next = old_top.ptr; new_top = {node, old_top.tag + 1}; } while (!top.compare_exchange_weak(old_top, new_top)); } Node* pop() { TaggedPointer old_top = top.load(); TaggedPointer new_top; while (old_top.ptr) { new_top = {old_top.ptr->next, old_top.tag + 1}; if (top.compare_exchange_weak(old_top, new_top)) { return old_top.ptr; } } return nullptr; } };

这里每个成功修改top指针的操作都会让tag加一,compare_exchange_weak强对比整个结构体。如果一个节点被弹出后又重新入栈,指针虽然回到原位,但tag已经变了,另一线程CAS必然失败,于是必须重新读取最新状态,ABA就被轻松破解。

4.3 版本号方案的边界条件:标签溢出与性能成本

所有美好方案都得讲阶层成本。每做一次push或pop,不仅要比较指针,还要比较整个双字结构体,在x86上这会触达cmpxchg16b指令,这个指令比普通cmpxchg要贵不少。但这在绝大多数场景下都是“可接受的昂贵”,因为换来的是大量环节的安全。

至于tag溢出,理论上有风险,实际中几乎可忽略——假设每秒执行一亿次修改,64位计数器也需要数百年才可能溢出。真到迫在眉睫的程度,可以考虑引入更复杂的分布式标记方案,但多数人一辈子也用不着。

5. 破解之法二:延迟释放与垃圾回收——从根上斩断“悬空引用”

5.1 核心思路:不让被移除的节点立刻回到内存池

ABA问题的很多破坏力来自节点被移除后,内存又被复用。那反过来,如果我不让旧节点立刻被释放,是不是就能让“复用”链条断裂?工程上是有成熟方案的,典型代表就是延迟回收机制。

做法很简单:每个线程维护一个“退役节点”列表,把从数据结构中摘除下来的节点先放进去,延迟一段时间(比如所有读线程都离开临界区之后)再真正归还给内存分配器。这个方案叫Hazard Pointer(危险指针)或者Epoch-Based Reclamation(基于代际的回收),都属于延迟释放的思路。

5.2 危险指针(Hazard Pointer):你用得上的简化版

危险指针的思路是:

  1. 每个线程持有一个全局可见的hazard pointer数组;
  2. 在读取某些节点前,把正在读的节点地址声明为“危险”;
  3. 其他线程想释放某个节点前,先查看有没有人声明了同一个地址;
  4. 如果有,就不能释放,扔进待回收列表;
  5. 如果没有,直接释放。

这样ABA中“内存被复用”这个关键环节就被切断了:节点地址不会在CAS期间被归还给allocator,指针就算绕回来,它指向的还是同一个有意义的对象,不是一块被污染的野内存。

5.3 选用延迟释放的注意点

延迟释放这种方案,一般不改变CAS比较逻辑,所以它主要用于解决“悬空引用”造成的二次伤害,而不是根治ABA本身。在工程中,我建议你和版本号方案搭配使用:版本号保证逻辑不误判,延迟回收保证内存安全,双管齐下,才能形成一套无锁结构的地基。

如果用的是C++11,还可以考虑shared_ptr配合atomic_load,但对无锁结构说,每次访问都原子引用计数,那开销基本可以把无锁性能红利全吃掉,所以我一般不当主力方案推荐。

6. 工程实战:用AtomicStampedReference类比打通Java和C++的桥梁

6.1 Java里你不手写版本号也能直接封死ABA

很多读者可能从Java那边过来,Java实现同样的安全栈非常简单:

import java.util.concurrent.atomic.AtomicStampedReference; public class SafeStack { AtomicStampedReference<Node> top = new AtomicStampedReference<>(null, 0); public void push(Node node) { int[] stamp = new int[1]; Node oldTop; do { oldTop = top.get(stamp); node.next = oldTop; } while (!top.compareAndSet(oldTop, node, stamp[0], stamp[0] + 1)); } public Node pop() { int[] stamp = new int[1]; Node oldTop; Node newTop; do { oldTop = top.get(stamp); if (oldTop == null) return null; newTop = oldTop.next; } while (!top.compareAndSet(oldTop, newTop, stamp[0], stamp[0] + 1)); return oldTop; } }

这套代码一眼就能看出安全设计:compareAndSet同时比较引用和stamp,每次更新都驱动stamp递增。ABA不再可能蒙混过关。

6.2 C++里也有AtomicStampedRef?没有,但我们用结构体模拟

C++标准库没有内置AtomicStampedReference,于是就得靠前一节写的TaggedPointer方案。如果平台支持16字节原子比较交换,代码可以直接编译运行;如果指针宽度较大,还可以选择把版本号压缩进指针的低位对齐位里。

比如malloc返回地址通常按8字节对齐,低3位恒为0,把版本号塞进低3位,不仅可以单个机器字完成CAS,还能省掉双字开销。代价是标记空间有限,且你要非常小心翼翼,别把版本信息和普通指针算术混淆。这种优化足以应对绝大多数单机并发栈的场景。

6.3 实战方案对比速查

方案实现成本开销防护能力适用场景
普通CAS最低最低对ABA毫无防护只有简单计数、无复用场景
版本号/tag中CAS变宽或加tag,开销略高彻底杜绝ABA逻辑误判队列、栈、链表等各种复用型结构
延迟释放(危险指针)高额外维护退役列表和危险指针数组防悬空引用,与版本号互补无锁内存管理、长期运行的服务器
引用计数中每次访问都有原子计数开销防悬空但无法防ABA逻辑适合某些树形结构,不适合高频无锁队列

这个表格对我的日常选型非常管用。核心结论就一句:如果是高并发、高复用、生命周期复杂的场景,不要试图省掉版本号,更不要把延迟释放当成可有可无的“优化点”。

7. 把ABA问题引到其他领域:不只是栈,不只是C++

7.1 队列和链表里同样会中招

很多工程师在栈上理解了ABA,就主观认为队列安全。实际上,任何“先记旧值、拿旧值取next/new节点、再做CAS”的循环体,都有ABA的空子可钻。无锁队列那套出队由tailCAS、入队也由tailCAS控制时,一个节点被出队再入队,仍然可能让另一端的CAS误判“旧tail”为当前tail。

早年我在设计一个多线程任务队列时,就吃过这种哑巴亏。出队线程读到一个tail指针,另一个线程大幅度清理队列,把一堆node全都丢回内存池,然后新线程又复用这些node地址。出队线程一醒来,CAS发现tail“还是那个地址”,直接更新过去,队列指针瞬间拓扑错乱。后来嵌套了tag才彻底安稳。

7.2 文件系统、数据库事务里也有ABA的影子

ABA问题不止在内存数据结构中出现。分布式系统里,很多“版本号/乐观锁”其实就是在和ABA赛跑:

  • 数据库更新时带一个version字段,UPDATE语句里带上version = 旧值,如果期间有别的连接改过,版本号对不上,更新失败——这就是传统意义上的“乐观锁”,本质上是防ABA。
  • 文件更新场景下,运维脚本先读取配置文件的mtime,再写入文件;如果文件被别的进程恰好改回原来的mtime,CAS可能就失效。

所以,ABA不单单是内存模型的专属术语,它其实是“无状态CAS类校验”的通用天敌,所有通过“状态值快照”来做安全控制的系统,都应按版本号思维规避。

7.3 理解ABA,才算真正理解无锁编程的“不变量”

我觉得,ABA问题最宝贵的地方是它逼迫你去想一个问题:你到底在锁住什么?你维护的不变量到底是什么?锁mutex时,不变量由临界区保护,没人能破坏;用CAS时,不变量靠的是“所有参与线程都遵循同一套比较规则”。如果你只比较一个浅层的值,那你的不变量就非常脆弱。版本号、危险指针、延迟释放,全都是在加固这个不变量,只不过加固的位置不一样。

想清楚这个底层逻辑,再看那些花哨的无锁队列实现,你一眼就能看出它们各自在防哪一类风险。

8. 踩坑笔记与高频问题排查

8.1 我在实际工程中踩过的三个和ABA相关的坑

第一个是“我加了自旋锁怎么还会数据错乱”。很多人以为compare_exchange_weak在失败时重试就安全了,其实重试只是在处理“值不一致”,ABA根本没触发重试,因为你比较到的值确实是旧的A。所以,只靠自旋,永远防不了ABA。

第二个是“GCC下用__sync_val_compare_and_swap做指针版本号”。这类老builtin函数只支持比较整数和指针类型,不支持双字结构体,你用它将版本号塞进指针还行,否则只能退回到锁或者用libatomic提供的新接口。我在老项目迁移时踩过这个坑:原本代码在x86_64很欢快,换到一个32位ARM设备上,双字CAS根本不可用,整个无锁结构直接编译失败。真碰到这种硬件环境,就得回退成加锁版本,或者强制指针按对齐存储来腾tag位。

第三个是“你以为加了tag就没问题了,结果tag位和指针位共用空间后互相干扰”。通常指针按16或8对齐时,低几位确实空闲,但如果你把指针做位运算或offset计算时不小心把低位信息带进去了,一个ptr | TAG_MASK就会让整个地址错位。这块需要极度小心,我只在注释和封装函数里开放tag操作,外部统一用get_ptr和get_tag来访问,绝不裸用位运算。

8.2 常见问题速查表

症状可能原因推荐排查动作
无锁栈偶发丢数据ABA触发,旧节点的next指针被覆写打印栈顶地址和tag变化,确认是否有节点复用
无锁队列运行时崩溃段错误悬空引用,节点被提前释放引入危险指针或退役列表,观察崩溃点是否在访问next
性能暴跌,CPU占用飙升版本号CAS太频繁,自旋重试率高减小结构竞争粒度,或换分段队列
compare_exchange一直失败版本号一直在变化,说明设计上操作频率过高换用批量回收、批量操作路径,减少对顶层指针的修改次数
编译平台差异导致不可用使用非平台支持的双字CAS用std::atomic<16字节结构体>或者转移至其他并发容器实现

8.3 调试无锁代码的硬件级建议

无锁代码出问题,最忌靠肉眼盯代码。我建议在本地用TLA+或者模型检查工具对算法做验证,同时配合-fsanitize=address检测内存访问越界和释放后使用,一旦ABA导致悬空引用,ASan会非常高概率地报错,这比你想破头复盘执行顺序要高效得多。线上环境则建议开启可观测的计数器——记录CAS失败总次数、tag更新次数、退役节点数量,数值一旦异常飙升,往往就是并发行为发生偏离的前兆。

9. 从“会写无锁代码”到“会用无锁代码”的最后一公里

9.1 代码贴上“无锁”标签,不等于高枕无忧

我见过很多团队看到某些知名开源库用了无锁栈,就直接照搬到业务里,结果业务高峰期出现幽灵数据。其实人家多用了一套“内部回收与隔离机制”,不单纯的依赖CAS裸奔。你搬走的时候如果把辅助机制丢了,等于穿着防弹衣只留了个头盔。

无锁编程里有一个逃不开的铁律:一份无锁数据结构的安全,是由所有读写该结构的线程共同维护的。某个线程用了不安全的内存释放策略,整个结构都可能被拖下水。所以在引入无锁结构时,要审查它的完整生命周期,而不是只看CAS调用点是否规范。

9.2 不要为了无锁而无锁

无锁听起来高级,但它难写、难查、难维护。只有当并发压力足够大、临界区足够短、锁竞争足够激烈时,才值得把常规队列或栈替换为无锁实现。普通业务系统用mutex配合普通容器,适当优化后性能完全够用。很多人就是被“无锁”两个字的魅惑搞到深夜调试段错误,还自我安慰这是成长的必经之路。

9.3 我自己的工程判断标准,分享给你

做替换决定前,我一定先回答三个问题:数据结构的生命周期是不是复杂到无法用锁守护?当前锁实现是不是已经成为性能瓶颈?团队里有没有足够有经验的成员来review和维护无锁代码?三个答案如果都是“是”,再动手换。对绝大多数人来说,先能把加锁版本写对,才配谈无锁优化。

从这段无锁“折腾史”里,我最大的体会就是:无锁编程从表面看是性能游戏,骨子里却是严谨的逻辑博弈。ABA问题不会因为你换了高级语言就自动消失,也不会因为你用了最新处理器就被硬件忽略。它存在于“状态只能代表当下、无法代表历程”的天然缺陷里面。破解它的思路也不复杂——给状态加上变迁痕迹,或者让被移除的资源不立即拿来复用。想通这个,再去看任何无锁数据结构,你都不会再被云遮雾罩,反而能精准定位它可能翻车的地方。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询