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 指针可能已经被覆盖成无效数据。
压测数据不会撒谎
别信直觉。我们搭了一套无锁栈,模拟电商秒杀扣减库存。压测环境: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 的平台上原子完成。
三个让你痛不欲生的坑
坑一:指针包装的位宽陷阱。 在 32 位系统上,你没法把一个 32 位指针加一个 32 位计数器塞进一个 64 位原子变量(根本没有双宽 CAS 指令)。硬来?你会发现 top 8 位被截断,计数器翻转后复用,ABA 照样发生。解决之道——要么切到 64 位,要么用两级索引(比如 数组下标代替指针,下标高位作版本),要么干脆用锁。别死磕无锁,工程里合适的才是最好的。 坑二:语言包装类的“相等”陷阱。 Java 的 AtomicStampedReference 内部用 `Pair作者|大讲堂
排版|大讲堂
审核|满满
大讲堂