跳至内容
原子操作不是魔法:sync/atomic、无锁状态与适用边界

原子操作不是魔法:sync/atomic、无锁状态与适用边界

2026年6月24日·
yanlong

Atomic 能保证一次内存操作不可分割,却不能自动让一段业务逻辑变成原子事务。很多错误的无锁代码,每一行都用了 sync/atomic,组合起来仍然违反不变量。

官方文档把 atomic 定位为实现同步算法的低层原语,并明确建议:除特殊底层场景外,优先使用 channel 或 sync 包。这个建议不是保守,而是无锁状态比看起来更难组合。

优先使用 typed atomic

现代 Go 提供 atomic.BoolInt32Int64Uint32Uint64UintptrPointer[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 按值传递,会复制状态并让调用方误以为仍在操作同一个计数器。

单字段原子,不等于多字段一致

上面的 requestsfailures 各自更新是原子的,但下面的读取仍可能暂时看到 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 或可能失败的多个步骤;
  • 仅仅为了让代码看起来“无锁”。

最好的无锁代码通常很短,因为它保护的状态模型也很小。

延伸阅读