Go CompareAndSwap 循环为什么仍可能出现 ABA 问题
来源:17golang原创
时间:2026-10-06 19:06:18 466浏览 收藏
会。Go 的 CompareAndSwap 原子性只保证“比较和替换”这一刻不可分割,并不记录比较线程等待期间发生过哪些中间状态。线程 T1 读到指针 A 后暂停,线程 T2 完成 A→B→A,T1 再用旧指针 A 去 CAS,仍可能返回成功;这就是 ABA。解决重点不是把循环写得更紧,而是确认共享状态是否需要携带版本或其他不会回退的身份信息。
如果 CAS 的成功条件只包含一个可回到原值的字段,那么“现在还是 A”不等于“期间一直是 A”。对可复用节点、无锁栈和资源生命周期敏感的结构,应把指针与版本号放进同一个原子状态,或改用锁、通道等更高层同步方案。
atomic.Pointer[T].CompareAndSwap比较的是旧指针与当前指针是否相同。- A→B→A 会隐藏中间变化,尤其危险于节点地址可复用的无锁结构。
- 版本号必须和指针在同一个原子比较范围内更新,两个独立原子变量不能拼出可靠 CAS。
- 修复后仍要检查节点回收、悬空访问、重试上限和退出条件。
先把 CAS 比较的对象说清楚
官方 sync/atomic 文档把 CAS 描述为:如果地址中的当前值等于 old,就写入 new 并返回 true,否则返回 false。atomic.Pointer[T] 的方法也是这个语义,只是把指针类型封装得更安全、可读。
因此,下面的条件只能回答“此刻是不是同一个指针”:它不能回答这个对象是否刚被移出链表、重新初始化,又被放回了同一个位置。ABA 的 A、B 不是某个固定的 Go 错误码,而是一次并发观察到的状态序列。
// 这个循环只把 head 的当前指针与 old 比较。
// 如果其他线程让 head 经历 A->B->A,CAS 仍可能成功。
var head atomic.Pointer[Node]
func popOnce() *Node {
old := head.Load() // 记录本轮准备移除的节点
if old == nil {
return nil
}
next := old.next // 读取 old 的后继,生命周期必须由设计保证
if head.CompareAndSwap(old, next) {
return old // 成功只说明当前 head 仍等于 old
}
return nil // 失败后重新读取,不能复用过期 next
}
沿着两个线程还原 ABA 时序
假设无锁栈的头节点最初是 A,A 的后继是 X。T1 读取 old=A、next=X 后被调度出去。此时 T2 先把 head 从 A 换成 B,又完成一次弹出和回收流程,最后把一个节点放回头部,使 head 再次指向 A。

T1 恢复后执行 head.CompareAndSwap(old, next)。比较器看到的仍是地址 A,于是返回成功,但 T1 手里的 next 可能已经来自旧链表,甚至对应一个已经重新初始化的节点。这里最危险的不是“CAS 失去原子性”,而是比较条件表达得不够完整。
判断普通数值循环是否真的有 ABA 风险
看到 CompareAndSwap 循环,不要立刻把所有问题都叫 ABA。对只表示计数的整数,A→B→A 未必改变业务含义;而下面几类状态通常要重点排查:
| 共享状态 | 真正要确认的风险 | 优先措施 |
|---|---|---|
| 无锁栈/队列节点指针 | 节点被移除、复用后地址或指针又相同 | 指针+版本,配合安全回收 |
| 资源句柄或槽位 | 旧句柄看起来仍有效,但所有权已换过一轮 | 代数、租约或锁保护 |
| 纯计数器 | 只关心数值增减,不关心对象身份 | 先验证不变量,通常不必套指针方案 |
另一个常见误区是把两个独立原子变量当成一个整体:先 CAS 指针,再递增版本号。线程可能在两步之间观察到半更新状态,ABA 仍然存在。完整比较对象必须一次性提交。
用值与版本号一起做 CAS
可靠的设计是把状态表示为不可回退的组合,例如 (指针=A, version=1)。即使指针经历 A→B→A,版本也会变成 3,等待中的线程拿着旧组合 (A,1) 再比较就会失败。

Go 标准库提供的是多种单值原子类型和原子指针;“指针+版本”的打包方式要结合目标架构、对齐、内存回收策略和实现复杂度谨慎设计。若无法让组合状态在一次原子操作中完成,宁可使用互斥锁保护整个状态,也不要用两个 CAS 拼接出看似无锁的协议。
// 伪代码展示比较目标:业务实现需选择可一次原子更新的状态载体。
type Stamp struct {
ptr *Node // 当前节点身份
ver uint64 // 每次状态变更递增,不能随节点回退
}
func tryAdvance(old, next Stamp) bool {
next.ver = old.ver + 1 // A->B->A 也必须留下不同的代数
// 这里要求 CAS 同时比较 old.ptr 和 old.ver。
// 不能先 CAS 指针,再单独 Store 版本号。
return compareAndSwapWholeStamp(old, next)
}
补上回收、重试和退出边界
版本号只能解决“比较条件看不见中间变化”,不能自动解决节点已经被回收后的访问安全。实现无锁结构时还要回答三个问题:读到节点后谁负责延长其生命周期;节点何时允许复用;CAS 连续失败时是否有退避和退出条件。
排查时可以先画出状态不变量:成功弹出后 head 必须指向原后继,已发布节点不能被提前复用,失败重试必须重新 Load。再用 go test -race 发现数据竞争,用压力测试覆盖 T1/T2 的暂停窗口;Race 检测通过也不代表 ABA 协议正确,它们解决的是不同层面的问题。
相关问题
CompareAndSwap 返回 true 就代表整个操作安全了吗?
不代表。它只证明指定地址在比较瞬间等于 old,并且替换成功;节点生命周期、业务不变量和后续读取仍需独立保证。
把指针改成整数就能避免 ABA 吗?
不能。只要整数也可能回到旧值,比较条件就可能隐藏中间变化。关键是加入不可回退的代数或改用整体锁保护。
为什么不直接用 mutex?
如果临界区短、竞争可控且正确性优先,mutex 往往更容易审查。只有明确测量到锁竞争成本,并且能证明回收与内存序协议时,才值得维护无锁实现。
CAS 循环失败后应该怎么重试?
重新 Load 当前整体状态,再计算 next;不要继续使用上一次失败的后继指针。连续失败还应设置退避、上限或降级路径。
把问题归纳成一句话:CAS 保证的是一次比较替换,不是历史证明。只要业务需要区分“从未变化的 A”和“变化后又回到 A”,就必须把版本、所有权或更高层同步机制纳入成功条件。
-
502 收藏
-
502 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
455 收藏
-
218 收藏
-
217 收藏
-
268 收藏
-
175 收藏
-
245 收藏
-
463 收藏
-
107 收藏
-
408 收藏
-
350 收藏
-
312 收藏
-
232 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习