跳到主要内容
约 12 分钟6 张卡

Go 原子操作

原子操作是其他并发访问无法观察到中间状态的一次操作。Go 的 sync/atomic 可以原子地读取、替换一个值,或者把比较与修改合成一步;它适合计数、状态转换和对象入口的发布,但不会自动保护一段函数里的所有操作。

判断原子代码是否正确,首先要说清楚需要一起生效的是什么:一个计数值、一次带条件的更新,还是多个字段之间的约束。本文以 Go 1.22.12 为准,使用带类型的原子对象解释这些边界。

1. 面试核心​

先记住这几条,再读正文
  • atomic.Int64、atomic.Bool 和 atomic.Pointer[T] 等类型把原子访问封装成方法。零值可以使用,首次使用后不能复制;包含这些字段的结构体也要避免按值复制。
  • Add 返回新值,Swap 返回旧值,CompareAndSwap 返回是否更新成功。先 Load 再 Store 是两次操作,不能代替一次 Add;没有数据竞争也可能丢失更新。卡 · 两次原子操作仍会丢失更新
  • CAS 把“当前值仍等于预期值”和“写入新值”合成一次原子操作。失败后是否重试由调用者决定;重试需要重新读取、重新检查条件,不能沿用已经失效的计算结果。卡 · 用 CAS 保留判断条件
  • Go 原子操作具有顺序一致性。原子发布指针能发布此前构造好的状态,但不保护对象发布之后的修改;多个关联字段要由同一次状态更新或同一把锁保护。卡 · 发布与多字段约束
  • atomic.Value 可以保存统一动态类型的值,不能存入 nil 接口,也不能中途更换动态类型。保存切片或 map 不会复制其底层数据,使用 CAS 还需要考虑值是否可比较。卡 · atomic.Value 的类型约束
  • CAS 只比较当前值,不检查变化历史。值经过 A → B → A 后,旧的预期值仍可能匹配;是否需要版本信息,取决于算法是否依赖中间没有发生变化。卡 · 当前值不能说明变化历史
  • 使用原子操作不等于整个算法无锁,也不保证某个 goroutine 能在有限次数内成功。单点竞争、重试、分配和外部动作都需要独立判断。卡 · 用 CAS 保留判断条件

2. 原子类型与操作​

2.1 类型与零值​

带类型的原子对象把存储位置和访问方法放在一起,调用方不直接读写其内部字段:

类型保存的值零值
atomic.Bool布尔状态false
atomic.Int32、atomic.Int64有符号整数0
atomic.Uint32、atomic.Uint64、atomic.Uintptr无符号整数0
atomic.Pointer[T]*T 指针nil 指针
atomic.Value动态类型一致的值Load() 返回 nil 接口

前四类提供 Load、Store、Swap 和 CompareAndSwap,整数类型还提供 Add。Value 的类型检查发生在运行时,后文单独说明。

原子对象首次使用后不能复制。例如,把已经使用过的 atomic.Int64 作为参数按值传递,或复制包含它的结构体,都违反使用约定。通常通过指针共享同一个对象;复制出一个数值快照则应调用 Load()。

包级函数 atomic.LoadInt64(&n) 等仍然可用,但必须正确管理同一存储位置的所有访问。不能一边原子写 n,另一边无保护地普通读取 n。在 ARM、386 和 32 位 MIPS 等平台,包级 64 位原子函数还要求调用方保证对齐;atomic.Int64、atomic.Uint64 自行保证所需对齐。

2.2 返回值与原子范围​

package main

import (
"fmt"
"sync/atomic"
)

func main() {
var count atomic.Int64
fmt.Println(count.Load())
fmt.Println(count.Add(3))
fmt.Println(count.Swap(8))
fmt.Println(count.CompareAndSwap(3, 10), count.Load())
fmt.Println(count.CompareAndSwap(8, 10), count.Load())
}

依次输出 0、3、3、false 8、true 10。Add(3) 返回增加后的值,Swap(8) 返回替换前的 3;第一次 CAS 预期值不匹配,没有修改,第二次匹配才写入 10。这里没有其他 goroutine 修改 count,因此随后 Load() 的值可以确定。

CAS(compare-and-swap,比较并交换)在一个原子步骤内检查当前值是否等于 old,匹配才写入 new。它不会在返回 false 后自动重新执行业务判断,也不会把相邻的其他变量纳入保护。

Add 不会检查业务上下限。计数表示库存、额度或长度时,需要另外判断是否允许增加或减少;整数的溢出规则也不会因为使用了原子方法而变成自动报错。

3. 多步操作与条件更新​

3.1 两次原子操作仍会丢失更新​

下面让两个 goroutine 都完成读取,再允许它们写回。所有对 count 的访问都使用原子方法:

package main

import (
"fmt"
"sync"
"sync/atomic"
)

func main() {
var count atomic.Int64
loaded := make(chan struct{}, 2)
write := make(chan struct{})
var wg sync.WaitGroup

for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
old := count.Load()
loaded <- struct{}{}
<-write
count.Store(old + 1)
}()
}
<-loaded
<-loaded
close(write)
wg.Wait()
fmt.Println(count.Load())
}

输出确定为 1。两个 goroutine 都读到 0,之后分别写入 1;同步通道只是让这次交错明确发生,没有替它们合并读改写操作。如果需要每次调用都增加一次,应使用 count.Add(1),两次完成后结果才是 2。

这段程序没有数据竞争,却没有实现预期的累计效果。-race 不会替我们检查这种业务错误;数据竞争的判断条件见 Go 内存模型。

3.2 用 CAS 保留判断条件​

假设一个进程内有一组名额,要求一次领取 n 个,且不能把剩余数量减成负数。只有当前数量仍等于读取时的数量,才能提交这次扣减:

package main

import (
"fmt"
"sync/atomic"
)

func reserve(remaining *atomic.Int64, n int64) bool {
if n <= 0 {
return false
}
for {
old := remaining.Load()
if old < n {
return false
}
if remaining.CompareAndSwap(old, old-n) {
return true
}
}
}

func main() {
var remaining atomic.Int64
remaining.Store(5)
fmt.Println(reserve(&remaining, 3), remaining.Load())
fmt.Println(reserve(&remaining, 3), remaining.Load())
fmt.Println(reserve(&remaining, 0), remaining.Load())
}

输出 true 2、false 2、false 2。假设初始值非负,且其他访问也遵守名额更新协议,成功的 CAS 保证检查依据与扣减生效时的值相同。因为 n > 0 且 old >= n,本次减法不会得到负数。

并发领取时,两个调用者可能都读到 5。其中一个先扣成 2,另一个的 CAS 就会失败;它必须重新读取 2 并重新检查是否足够,不能直接沿用 5-3。成功的 CAS 是一次成功领取的线性化点,也就是可以把这次操作视为瞬间生效的位置。

循环里的计算可能执行多次,所以这里只做读取、条件判断和候选值计算。发消息、扣款等外部动作不能跟着每次重试执行。即使放到 CAS 成功之后,内存扣减和外部动作之间仍可能失败,需要业务自己的幂等、补偿或持久化协议。

reserve 只管理当前进程中的一个计数,不提供持久化,也不是跨进程库存方案。

4. 原子指针与复合状态​

4.1 指针身份与对象内容​

atomic.Pointer[T] 原子地保存一个 *T。它的 CAS 比较是否指向同一个对象,不会按结构体字段逐个比较:

package main

import (
"fmt"
"sync/atomic"
)

type Config struct {
Limit int
}

func main() {
a := &Config{Limit: 10}
b := &Config{Limit: 10}
var current atomic.Pointer[Config]
current.Store(a)

fmt.Println(current.CompareAndSwap(b, nil))
old := current.Swap(nil)
fmt.Println(old == a, current.Load() == nil)
}

输出 false 和 true true。a 与 b 内容相同,但指向两个不同的非零大小对象,所以 CAS 不匹配。Swap(nil) 则把入口清空,并返回原来的 a。

清空入口不会撤销其他 goroutine 已经取得的指针,也不会等待它们结束使用。即使 Go 的垃圾回收仍能维持对象内存有效,对象关联的文件、连接等资源能否关闭,也要由使用协议决定。原子替换入口不能代替资源使用者的退出与回收协调。

4.2 发布与多字段约束​

Go 原子操作具有顺序一致性:可以把它们理解为按某个保持各 goroutine 内顺序的总顺序执行。观察到一次原子写入效果的原子读取,还建立了相应的同步关系,能发布写入前完成的普通字段初始化。完整推导与不可变快照示例见 Go 内存模型。

如果 min、max 必须共同满足 min <= max,分别给它们套上 atomic.Int64 并不能让两次更新一起生效。读取方仍可能在两次写入之间看到组合状态。可以选择:

  • 把完整检查和修改放在同一把锁保护的临界区中。
  • 构造包含两个字段的不可变状态,再原子替换一个指针;读取方一次 Load 取得状态后,始终读取这一份对象。

第二种方式仍要处理多写入者。多个写入者都根据旧状态计算,然后直接 Store,可能覆盖彼此的修改;需要累积修改时,应使用锁,或用 CAS 检查旧状态后重试。替换整个配置而非累积修改时,则应明确哪个写入者有最终发布权。

不可变必须覆盖实际共享的数据。 状态里有 map、切片或指针时,浅复制结构体不会自动得到独立的数据。发布后继续通过旧别名修改这些内容,仍可能与读取者竞争。

5. atomic.Value 的类型约束​

atomic.Value 通过 any 保存和加载值。它适合需要原子替换整个值、但不使用 atomic.Pointer[T] 表达的场景;调用者需要遵守统一动态类型的约定:

package main

import (
"fmt"
"sync/atomic"
)

func main() {
var data atomic.Value
fmt.Println(data.Load() == nil)
data.Store([]int{1, 2})
fmt.Println(data.Load().([]int)[1])

var pointer *int
var optional atomic.Value
optional.Store(pointer)
fmt.Println(optional.Load() == nil, optional.Load().(*int) == nil)
}

输出 true、2、false true。尚未存储的 Value.Load() 返回 nil 接口;带类型的 nil 指针可以存入,因为该接口值具有动态类型 *int。接口与带类型 nil 的区别见 Go 接口。

需要分别检查以下条件:

操作条件与结果
Store(nil)、Swap(nil)、CompareAndSwap(old, nil)新值是 nil 接口,发生 panic
首次保存 []int,之后保存 int动态类型不一致,发生 panic;两个不同的定义类型也不能混用
Swap(new)返回旧值;尚未保存过值时返回 nil
CompareAndSwap(old, new)比较当前值与 old;类型要一致,比较还要满足可比较性要求
对空 Value 调用 CompareAndSwap(nil, new)new 合法时可以完成首次初始化

Value 可以 Store 切片或 map,但它们不可比较,不能因此照搬到已存有这种值的 CAS 比较中。可比较结构体按字段比较;结构体中的接口字段若实际装有切片,比较仍可能 panic。类型关系见 Go map 的键类型与相等性。

Store 不会深拷贝值,Load 也不会为每个读取者生成独立的底层对象。使用 Value 发布 map 时,应在发布后停止修改该 map,更新时另建所需状态并发布,或采用能够同步内部修改的协议。

6. ABA、进展与成本​

6.1 当前值不能说明变化历史​

ABA 是值从 A 变成 B 又变回 A 的过程。CAS 只检查当前值,因此下面的比较仍能成功:

顺序goroutine 甲goroutine 乙当前值
1读取 old = 00
2从 0 改成 11
3从 1 改回 00
4CompareAndSwap(old, 2) 成功2

普通计数若只关心当前数量,这段历史未必有影响;如果 0 表示“这个任务从未开始”,中间已经启动又重置的事实就不能被忽略。指针也可能出现同一对象被移除后重新放回的情况,Go 有垃圾回收并不能排除这种逻辑复用。

需要检测历史时,可以把版本和值作为同一份状态,一起参与原子比较与更新;把版本单独放在另一个原子变量里,仍不能自动保证二者来自同一版本。也可以每次发布新的不可变状态对象,并约束旧状态不会重新发布。有限位数版本还要考虑回绕,不能把“加了版本”当成完整正确性证明。节点复用的具体交错见 CAS。

6.2 重试与外部动作​

CAS 循环可能因持续竞争反复失败。一个 goroutine 总是输给其他更新者,仍不违反原子方法的契约;原子操作没有承诺公平性或每个调用者的重试次数上限。

无锁算法的进展保证需要针对完整算法分析。循环中如果包含互斥锁、可能阻塞的回调或其他外部等待,就不能仅凭用了 CAS 宣称整体无锁。队列还必须正确处理槽位发布、读取、容量与复用,单独原子更新一个索引不足以证明整个队列正确;具体交接及预占停顿的例子见并发队列。

性能也取决于访问方式。大量 goroutine 更新同一个原子计数仍会竞争同一存储位置;CAS 失败还会增加重复计算。应结合实际负载比较原子方案与锁方案,而不是由“没有显式 Lock”推导出一定更快。

7 · 面试题卡片​

本页题目与参考回答

7.1 Load 和 Store 都是原子的,为什么递增仍可能丢失?​

考察重点:单次访问、读改写整体与业务正确性的区别。

相关内容:两次原子操作仍会丢失更新

参考回答

两次操作之间允许其他 goroutine 插入访问,原子 Load 和 Store 不会自动合成一次递增。 两个调用者都读到 0 后分别写 1,最终就只有 1,即使每次访问都没有数据竞争。

单纯累计应使用 Add(1);需要判断后更新时,使用正确的 CAS 循环或锁保护完整操作。Add 返回新值,Swap 返回旧值,不能混淆返回值的含义。

7.2 如何原子地领取名额,并保证剩余数量不为负?​

考察重点:输入边界、条件更新、失败重读与线性化点。

相关内容:用 CAS 保留判断条件

参考回答

在初始数量非负、其他更新遵守同一协议的前提下,先拒绝非正领取量,再用 CAS 同时保留余额判断和扣减。 读取 old,不足则失败;足够时尝试把 old 改成 old-n。

CAS 成功才算领取生效。失败时重新读取并检查,不能继续使用旧余额;成功的 CAS 是线性化点。这个协议只保护当前进程的计数,不会同时提交订单、消息或外部扣款。

7.3 用 atomic.Pointer 保存配置后,能否直接并发修改其中的 map?​

考察重点:入口原子性、对象发布、深层别名与复合状态。

相关内容:发布与多字段约束、指针身份与对象内容

参考回答

不能仅凭指针是原子的,就无保护地并发修改所指对象里的 map。 原子方法保护入口的读取、替换与发布,不会递归保护对象内部。

可以发布完整构造、之后不再修改的状态,读取方一次加载后使用同一份状态;需要更新 map 时另建数据,并按写入者协议替换入口。也可以用锁协调内部访问。浅复制结构体和清空入口都不能让旧使用者的可变别名消失。

7.4 atomic.Value 能保存切片、nil 和不同类型的值吗?​

考察重点:动态类型一致性、nil 接口与可比较性。

相关内容:atomic.Value 的类型约束

参考回答

可以保存切片,但后续必须保持动态类型一致,不能存入 nil 接口。 保存 []int 后改存 int 会 panic;带类型的 nil 指针可以保存,加载得到的接口本身并不是 nil。

Store 接受某个值,不代表该值适合 CAS。对已存有切片或 map 的值执行需要比较它们的 CAS,会因不可比较而 panic。Value 也不深拷贝底层数据,发布后的共享修改仍需要同步。

7.5 Go 有垃圾回收,为什么仍可能遇到 ABA?​

考察重点:值相等、变化历史、对象复用与版本协议。

相关内容:当前值不能说明变化历史

参考回答

垃圾回收不会阻止业务把数值或同一个对象重新放回原位置,CAS 仍只看到当前值。 A → B → A 之后,旧的预期值 A 可以再次匹配。

只依赖当前数值时未必需要处理;依赖“中间未变化”或旧节点关系时,需要共同比较版本和值,或限制旧状态复用。版本回绕、对象发布与生命周期还需要按完整协议检查。

7.6 用 CAS 循环更新状态,就能保证无锁、快速且外部动作只执行一次吗?​

考察重点:原子方法与完整算法、重试副作用、进展和成本。

相关内容:用 CAS 保留判断条件、重试与外部动作

参考回答

这些结论都不能只由使用了 CAS 推出。 重试中的外部动作可能重复执行;移到成功之后,也仍存在状态已更新而外部动作失败的窗口。

整体是否无锁要检查完整路径,某个 goroutine 也可能持续重试而得不到公平进展。原子热点、重试和候选对象分配都有成本,需要实际负载验证性能;外部动作的幂等与恢复由业务协议负责。

8. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明