原子操作不是魔法:sync/atomic、无锁状态与适用边界
Atomic 能保证一次内存操作不可分割,却不能自动让一段业务逻辑变成原子事务。很多错误的无锁代码,每一行都用了 sync/atomic,组合起来仍然违反不变量。
官方文档把 atomic 定位为实现同步算法的低层原语,并明确建议:除特殊底层场景外,优先使用 channel 或 sync 包。这个建议不是保守,而是无锁状态比看起来更难组合。
优先使用 typed atomic
现代 Go 提供 atomic.Bool、Int32、Int64、Uint32、Uint64、Uintptr 和 Pointer[T]。它们比旧的 atomic.AddInt64(&value, 1) 更不容易用错地址和类型。
type Metrics struct {
requests atomic.Uint64
failures atomic.Uint64
}
func (m *Metrics) Record(err error) {
m.requests.Add(1)
if err != nil {
m.failures.Add(1)
}
}这些类型首次使用后不能复制。把包含 atomic 字段的 struct 按值传递,会复制状态并让调用方误以为仍在操作同一个计数器。
单字段原子,不等于多字段一致
上面的 requests 和 failures 各自更新是原子的,但下面的读取仍可能暂时看到 failures > requests:
failed := m.failures.Load()
total := m.requests.Load()因为两次 Load 之间,其他 goroutine 可以推进状态。若业务要求两个值形成一致快照,就需要:
- 一把锁同时保护两个字段;
- 把状态编码进一个原子值;
- 或发布一份不可变快照。
Atomic 保护的是某个操作,不是你脑中的业务事务。
CAS 是乐观重试,不是普通 if
Compare-And-Swap 只有在当前值仍等于预期值时才写入:
type Limiter struct {
inFlight atomic.Int64
limit int64
}
func (l *Limiter) TryAcquire() bool {
for {
current := l.inFlight.Load()
if current >= l.limit {
return false
}
if l.inFlight.CompareAndSwap(current, current+1) {
return true
}
// 状态已变化,重新读取并检查条件。
}
}
func (l *Limiter) Release() {
if next := l.inFlight.Add(-1); next < 0 {
panic("limiter: release without acquire")
}
}CAS 循环适合状态很小、冲突较低、重试开销有限的场景。高竞争下,大量 goroutine 会反复失败并消耗 CPU;一把 Mutex 可能更稳定。
这段 Limiter 仍不提供排队、公平性和 Context 取消。生产限流通常更适合 channel semaphore 或加权信号量。示例只是展示 CAS 的结构。
原子状态机要把转换规则写清楚
const (
stateIdle int32 = iota
stateRunning
stateClosed
)
type Worker struct {
state atomic.Int32
}
func (w *Worker) Start() error {
if !w.state.CompareAndSwap(stateIdle, stateRunning) {
return ErrInvalidState
}
return nil
}一旦状态超过两三个,或者转换伴随资源创建、错误回滚和等待,锁通常比 CAS 更容易保证完整不变量。无锁状态机不能在 CAS 成功后“顺便做一堆可能失败的事”而没有补偿协议。
atomic.Pointer 发布不可变快照
读多写少的配置很适合 copy-on-write:
type Config struct {
Timeout time.Duration
Routes map[string]string
}
type ConfigStore struct {
current atomic.Pointer[Config]
}
func (s *ConfigStore) Load() *Config {
return s.current.Load()
}
func (s *ConfigStore) Store(next *Config) {
s.current.Store(next)
}关键不在 Pointer,而在“发布后不可变”。如果读者拿到 *Config 后还能修改 Routes,atomic 只安全地发布了一个会被并发修改的 map。
构建新快照时深拷贝可变字段:
func cloneConfig(source *Config) *Config {
next := *source
next.Routes = maps.Clone(source.Routes)
return &next
}这种模式让读路径无锁,写路径承担复制成本,适合配置、路由表和规则集,不适合高频写的大对象。
atomic.Value 适合统一类型的整值发布
var current atomic.Value
current.Store(&Config{})
cfg := current.Load().(*Config)第一次 Store 决定具体类型,后续存入不同具体类型会 panic,Store nil 也会 panic。atomic.Pointer[T] 类型更明确;Value 适合需要存放非指针整值或兼容既有 API 的场景。
同样,Load 得到的对象必须按不可变值使用,除非对象内部另有同步。
Atomic 不替代生命周期管理
一个 closed atomic.Bool 能让调用方快速判断组件是否关闭,却不能等待后台 goroutine 退出,也不能保证网络连接已经释放:
if worker.closed.Load() {
return ErrClosed
}完整关闭通常还需要 cancel、WaitGroup 和锁来协调资源。原子 flag 只是快速路径或状态观察,不是关闭协议本身。
ABA 与“值又变回去了”
CAS 只比较当前位模式。状态从 A 变成 B 又变回 A 时,CAS 无法知道中间发生过变化,这就是 ABA 问题。对简单计数器通常无关紧要;对无锁链表、对象复用和指针算法可能破坏假设。
解决需要版本戳、避免地址过早复用或更成熟的数据结构。业务代码一旦开始手写这类算法,应认真评估一把锁是否已经足够。
什么时候选择 Atomic
适合:
- 独立计数器和统计值;
- 简单开关或小状态机;
- 不可变配置快照发布;
- 已经通过 profile 证明锁竞争显著的底层结构。
不适合:
- 多字段业务不变量;
- 需要公平排队或 Context 取消;
- 操作包含 I/O 或可能失败的多个步骤;
- 仅仅为了让代码看起来“无锁”。
最好的无锁代码通常很短,因为它保护的状态模型也很小。