CAS 原理与实战:Go 语言里什么时候该用无锁并发

雨后青山
雨后青山
发布时间:2026-08-25 | 91人浏览过
原创

前言

在 Go 并发代码里,很多人知道 `sync/atomic` 很快,却不一定清楚 CAS 到底解决了什么问题,又会在什么场景下失效。本文从比较并交换的底层逻辑讲起,再结合 Go 的 `atomic` 接口、计数器和自旋锁等实例,帮你判断它适不适合放进自己的性能关键路径。

在 Go 并发代码里,很多人一遇到共享数据就先上 Mutex,这通常没错,但在计数器、状态位这类非常短小的更新路径上,锁并不一定是成本最低的方案。CAS(Compare-And-Swap,比较并交换)正适合这类场景:它依赖硬件原子指令完成更新,不必把 goroutine 挂起再唤醒。

不过,CAS 也不是“比锁更高级”的通用替代品。下面按原理、Go 里的实现方式、几个典型用法以及它的局限性拆开讲,读完你可以更清楚地判断:某段并发逻辑该用原子操作,还是老老实实用互斥锁。

CAS 到底是什么

CAS(Compare-And-Swap)是一种无锁同步算法,用来在不加锁的前提下,让多个线程或协程安全地更新同一份数据。

它有三个核心参数:

  • 内存位置(V):要读取和更新的目标地址
  • 预期旧值(A):调用方认为当前应该看到的值
  • 新值(B):如果比较成功,准备写入的新值

它的规则很直接:只有当内存位置 V 当前的值等于 A 时,才把它改成 B;否则什么也不做,并返回失败。这个比较和写入必须是一个不可分割的原子操作。

用伪代码表示就是:

func CompareAndSwap(addr *T, old, new T) bool {
    if *addr == old {
        *addr = new
        return true
    }
    return false
}

真正的关键不在这段逻辑本身,而在于“比较”和“写回”不能被别的并发操作插进来。也正因为如此,CAS 才能在没有锁的情况下保证数据更新的一致性。

CAS 为什么能做到无锁同步

核心思路:乐观并发控制

CAS 采用的是乐观并发控制思路。它不先假设冲突一定会发生,也不急着加锁,而是先尝试更新:如果发现值已经被别人改过,就说明这次尝试失败,再重试即可。

Go 中 CAS 的典型应用场景示意,包括计数器、自旋锁、无锁结构和状态标志
CAS 在 Go 里最常见的四类用法这一图不展示代码,而是把几种典型用法的适用对象和代价摆在同一页,便于快速比较。
CAS 原理示意图:比较、交换与失败重试的基本过程
CAS 的一次更新是怎么完成的用纸面卡片把 CAS 的比较、写入、失败重试拆开,帮助读者建立“乐观并发控制”的直观模型。

这种方式在冲突不高时很有效,因为省掉了加锁、解锁以及可能的线程阻塞成本。很多高性能并发结构,正是建立在这种“尝试更新,失败重来”的模式上。

底层依赖硬件原子指令

CAS 不是单靠语言层面“约定”出来的,它依赖处理器提供的原子操作能力。常见架构里都有对应支持:

CAS 与 Mutex 的取舍图,强调优势、限制与选型边界
什么时候该用 CAS,什么时候该用 Mute把适用条件和常见风险放在同一张图里,比单纯列优缺点更容易形成选型判断。
  • x86 架构使用 CMPXCHG 指令
  • ARM 架构提供 LDREX/STREX 指令对或直接 CAS 指令
  • RISC-V 架构提供 AMOSWAP 和 LR/SC 指令对

这些指令的意义在于:CPU 能直接在硬件层面完成“读当前值、比较、条件写入”的原子过程,从而避免使用更重的软件同步机制。

Go 里的实现入口

在 Go 语言中,CAS 主要通过 sync/atomic 包暴露出来。常见函数包括:

func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)
func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)
func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)

这些接口覆盖了常见的整数和指针类型。底层实现由 Go 运行时和对应架构的汇编代码配合完成。

以 amd64 为例,CompareAndSwapInt32 最终会使用 x86 的 CMPXCHG 指令,并结合 LOCK 前缀保证原子性。LOCK 前缀的作用,是让 CPU 在执行相关指令时对共享内存访问保持原子语义,确保多处理器环境下结果仍然正确。

Go 里怎么用 CAS:几个典型场景

线程安全计数器

计数器是最典型、也最容易理解的 CAS 用法。思路是先读旧值,再计算新值,最后尝试交换;如果交换失败,说明中途被别的 goroutine 改过,重新再来。

type Counter struct {
    value int32
}

func (c *Counter) Increment() {
    for {
        oldValue := atomic.LoadInt32(&c.value)
        newValue := oldValue + 1
        if atomic.CompareAndSwapInt32(&c.value, oldValue, newValue) {
            return
        }
    }
}

func (c *Counter) Value() int32 {
    return atomic.LoadInt32(&c.value)
}

这种写法很适合“单个字段、更新逻辑很短、冲突概率可控”的场景。相较于加锁,它少了锁竞争和 goroutine 切换的成本,因此在高频简单更新中通常更有优势。

自旋锁

CAS 也可以拿来实现自旋锁(SpinLock)。它不是拿不到锁就睡眠,而是不断尝试把标志位从 0 改成 1:

type SpinLock struct {
    flag int32
}

func (sl *SpinLock) Lock() {
    for !atomic.CompareAndSwapInt32(&sl.flag, 0, 1) {
        // 可加入runtime.Gosched()避免过度占用CPU
        runtime.Gosched()
    }
}

func (sl *SpinLock) Unlock() {
    atomic.StoreInt32(&sl.flag, 0)
}

这种做法适用于临界区非常短的情况,比如只执行几个纳秒到微秒级操作。如果临界区稍长,或者竞争很激烈,自旋就会白白烧掉 CPU,得不偿失。

这里的 runtime.Gosched() 是一个很实际的细节:即便采用自旋,也最好适度让出调度机会,避免单纯忙等导致资源占用过高。

无锁数据结构

更进一步,CAS 还是很多无锁数据结构的基础,比如无锁栈、无锁队列等。这类结构的共同点是:不依赖传统互斥锁,而是通过原子更新指针或状态来完成并发协调。

它们的优点是可扩展性好,在高并发读写场景下可能比传统锁结构更有性能空间;但代价也明显,设计和验证难度会高很多,代码可读性通常也不如基于锁的实现。

状态标志更新

如果一个状态只能按特定规则从旧值转到新值,CAS 也很适合。典型例子是“未初始化 -> 初始化中 -> 已完成”这类状态机,或者某些任务只允许执行一次的标志更新。

这种场景的价值在于:更新动作不只是写一个新值,而是“只有在当前状态符合预期时才允许切换”。CAS 正好能把这个条件检查和状态修改合成一个原子步骤。

CAS 的优势、风险与使用边界

它为什么快

CAS 的优势主要集中在几类场景:

  • 无锁操作:不会像互斥锁那样让线程阻塞并发生上下文切换
  • 避免死锁:不依赖锁,也就不会形成传统意义上的死锁链路
  • 足够轻量:对简单字段更新来说,资源开销通常更低
  • 低竞争时性能好:冲突少时,一次 CAS 成功就能完成更新

所以它特别适合高频、短小、单变量的并发修改,例如计数、自增、自减、标志位切换等。

它的问题也很实际

CAS 的局限同样不能忽略:

  • ABA 问题:值可能从 A 变成 B,又变回 A,CAS 会误以为“它没变过”。常见解决方式是引入版本号,或者使用带标记的指针。
  • 自旋开销:如果竞争激烈,失败重试会不断消耗 CPU 时间。
  • 只能天然处理单个变量:一旦逻辑涉及多个字段的一致性,CAS 组合起来会迅速变复杂。
  • 实现和维护难度高:无锁算法对时序、内存可见性和边界条件要求更高。

换句话说,CAS 更像一把精细工具,而不是通吃所有并发问题的“性能开关”。

什么时候用 CAS,什么时候继续用 Mutex

实践里可以按下面这个思路判断:

  1. 简单操作优先考虑 CAS:例如计数器增减、状态位设置、只执行一次的标志更新。
  2. 复杂逻辑优先用 Mutex:尤其是需要同时保护多个变量、多个步骤必须一起成功时。
  3. 先看竞争程度:高竞争下,CAS 频繁失败重试,性能可能反而不如 Mutex。
  4. 控制自旋成本:拿不到就一直空转通常不是好策略,必要时应退让或换同步方式。
  5. 对 ABA 提前设防:只要涉及指针切换、链表节点复用等问题,就要考虑版本号或额外标记。

另外,Go 标准库中的 atomic.Value 也值得优先考虑。对于某些“整体替换、并发读取”的场景,它能比手写底层 CAS 逻辑更直接,也更不容易出错。

结论:把 CAS 用在它真正擅长的地方

CAS 是 Go 无锁并发编程里非常重要的一块基础能力。它借助硬件原子指令,在合适场景下能用比锁更低的成本完成同步,尤其适合计数器、状态位、自旋控制以及部分无锁结构的实现。

但它的价值建立在“场景合适”这个前提上。对大多数业务代码来说,Mutex 往往已经足够快,也更容易写对;只有在性能关键路径、更新逻辑足够简单且竞争模式清晰时,CAS 才值得优先上场。

真正有用的判断标准不是“能不能用 CAS”,而是“用了以后是否真的更简单、更快、也还能保证正确”。这也是写并发程序时最该守住的边界。

相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表