登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

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。

Go atomic.Pointer CompareAndSwap 的 ABA 时序,T1 读取 A 后 T2 将 A 改为 B 再改回 A
图1:CAS 只看到当前指针为 A 的 ABA 时序说明图,不是截图或运行证据。

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 版本化状态的指针与版本号绑定关系,A1 与 A3 的版本差异阻止 ABA
图2:指针与版本号绑定到同一原子状态的结构说明图,不是截图或运行证据。

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”,就必须把版本、所有权或更高层同步机制纳入成功条件。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>