ABA问题:你以为的原子操作,其实根本不原子

程序崩了。查了三天,竟是因为 CPU 的一次误判。说实话,我当时想砸电脑。ABA 问题——这个教科书里轻描淡写的东西,真落到生产环境,能让你脱层皮。 你也许背过概念:线程1从内存位置 V 读出值 A,线程2把 V 改成 B 又改回 A,然后线程1用 CAS 比较 V 的当前值跟原先读出的 A 相等,于是交换成功。问题就在这里,最后一次比较时,值虽然还是 A,但“此 A”已非“彼 A”。链表里,它可能指向一个已经释放又被重新分配的节点,内容全变了。

CAS 的致命幻觉

CAS(Compare And Swap)本身是个原子操作,X86 有 CMPXCHG 指令。它会用硬件锁住总线,保证“比较-交换”不可分割。但 ABA 问题恰恰钻了原子的空子。打个比方:你停车时扫了一眼车牌是“京A88888”,然后去缴费。回来时发现还是那辆车,于是放心地开走。殊不知,这期间车被调包了——原车开走,另一辆外观一模一样的车停了进来,连车牌都是假的。你比较了“外观和车牌”,相等,但车上的东西已经不一样了。 在无锁栈的 pop 操作里,栈顶节点地址就是那个“车牌”。线程 T1 读到栈顶指针 ptrA,指向节点 N1。然后 T1 被抢占。T2 连续 pop 并释放 N1,接着 push 新节点恰好分配到 N1 的地址(内存分配器重用了这块内存),新节点记为 N2,地址还是 ptrA,但数据不同了。T1 恢复后执行 CAS,比较栈顶指针仍为 ptrA,认为栈没变,于是将栈顶更新为 N1 的下一个节点——这可就乱了,因为 N1 早已不是之前的节点,其 next 指针可能已经被覆盖成无效数据。
ABA问题内存变化栈示意图
ABA问题内存变化栈示意图
看,这就是典型的“幻觉”。你那可怜的单次 CAS,根本感知不到中间那番颠鸾倒凤。

压测数据不会撒谎

别信直觉。我们搭了一套无锁栈,模拟电商秒杀扣减库存。压测环境:Xeon E5-2680, 32 核,64 GB 内存,线程数从 4 递增到 64,每个线程执行 100 万次 push/pop 混合操作。先跑没有 ABA 保护的原始版本,用的是 64 位指针直接 CAS。结果呢?线程数超过 8 就开始报错,堆栈里出现“Segfault”,每 10 万次操作大约触发 2~4 次 ABA 导致的节点错乱。到了 64 线程,Java 版甚至频繁抛出 NullPointerException,吞吐量下降到单线程的 5%,基本不可用。 然后换上带 Tagged Pointer 的版本,也就是把一个 128 位的原子变量拆成高 64 位指针、低 64 位版本号(有些实现用 48 位指针加 16 位计数器)。每次修改指针的同时递增版本号。C 语言里直接用 compiler 内置的 `__sync_bool_compare_and_swap_16` 或 C11 的 `atomic_compare_exchange_strong`,在支持双宽 CAS 的平台上原子完成。
带版本号防止ABA的CAS结构图
带版本号防止ABA的CAS结构图
压测结果很直接:0 错误。性能呢?带版本号的 CAS 比纯指针 CAS 平均慢了 8%~12%(因为一次原子操作需要 16 字节而非 8 字节,且总线锁定消耗略大),但在 64 线程下仍保持了线性扩展,吞吐量稳定在 400 万 ops/s 左右。用内存屏障更精细的 hazard pointer 方案,牺牲 15% 的性能,但能完全避免节点过早回收,杜绝地址复用。数据明摆着:这点性能开销,换来的是系统的稳定,值。

三个让你痛不欲生的坑

坑一:指针包装的位宽陷阱。 在 32 位系统上,你没法把一个 32 位指针加一个 32 位计数器塞进一个 64 位原子变量(根本没有双宽 CAS 指令)。硬来?你会发现 top 8 位被截断,计数器翻转后复用,ABA 照样发生。解决之道——要么切到 64 位,要么用两级索引(比如 数组下标代替指针,下标高位作版本),要么干脆用锁。别死磕无锁,工程里合适的才是最好的。 坑二:语言包装类的“相等”陷阱。 Java 的 AtomicStampedReference 内部用 `Pair` 存放引用和 stamp,CAS 比较的是整个 Pair 对象的引用,而不是实际的对象字段。倘若你的 T 是可变对象,CAS 成功只代表 Pair 没变,但原对象的状态可能被其它线程修改了。解决办法:将 T 设计为不可变对象,或者每次更新都创建新实例。如果你依赖对象内容作判断,别用引用比较,老老实实给对象加个版本字段,并用 synchronized 保护。 坑三:内存回收的时序黑洞。 有些无锁方案用 epoch-based reclamation(EBR) 或 RCU,推迟释放节点,避免被 CAS 误读。可万一你的线程在持有读端 epoch 时阻塞或异常退出,内存回收会无限期延迟,内存占用暴涨。我就遇过 GC 线程被 OOM killer 干掉的惨案。修正:给 epoch 入口加超时防御,或用 hazard pointer 显式标记正在访问的节点,虽然麻烦,但可控。 这些坑,没踩过的人觉得无所谓,踩过的人半夜两点还在改 bug。说到底,无锁编程不是炫技,而是对内存秩序的一种敬畏。 记住了,下次 CAS 之前,先问自己一句:这个值,真的还是那个值吗?
免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:ABA问题:你以为的原子操作,其实根本不原子
文章链接:https://lfdjt.com/info_23_7885.html