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


这种方式在冲突不高时很有效,因为省掉了加锁、解锁以及可能的线程阻塞成本。很多高性能并发结构,正是建立在这种“尝试更新,失败重来”的模式上。
底层依赖硬件原子指令
CAS 不是单靠语言层面“约定”出来的,它依赖处理器提供的原子操作能力。常见架构里都有对应支持:

- 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
实践里可以按下面这个思路判断:
- 简单操作优先考虑 CAS:例如计数器增减、状态位设置、只执行一次的标志更新。
- 复杂逻辑优先用 Mutex:尤其是需要同时保护多个变量、多个步骤必须一起成功时。
- 先看竞争程度:高竞争下,CAS 频繁失败重试,性能可能反而不如
Mutex。 - 控制自旋成本:拿不到就一直空转通常不是好策略,必要时应退让或换同步方式。
- 对 ABA 提前设防:只要涉及指针切换、链表节点复用等问题,就要考虑版本号或额外标记。
另外,Go 标准库中的 atomic.Value 也值得优先考虑。对于某些“整体替换、并发读取”的场景,它能比手写底层 CAS 逻辑更直接,也更不容易出错。
结论:把 CAS 用在它真正擅长的地方
CAS 是 Go 无锁并发编程里非常重要的一块基础能力。它借助硬件原子指令,在合适场景下能用比锁更低的成本完成同步,尤其适合计数器、状态位、自旋控制以及部分无锁结构的实现。
但它的价值建立在“场景合适”这个前提上。对大多数业务代码来说,Mutex 往往已经足够快,也更容易写对;只有在性能关键路径、更新逻辑足够简单且竞争模式清晰时,CAS 才值得优先上场。
真正有用的判断标准不是“能不能用 CAS”,而是“用了以后是否真的更简单、更快、也还能保证正确”。这也是写并发程序时最该守住的边界。













