🔍 登录 注册

Go 内存模型与 atomic 包 无锁计数器的正确写法

打 打工人 2026-10-23 👁 0 ⏱ 5min

Go 内存模型与 atomic 包 无锁计数器的正确写法

atomic.AddInt64 看起来一行就完事,但无锁编程的真实难点在于:什么操作才是原子的、什么顺序才是可观察的。搞错的后果通常是"测试环境永远复现不出来"的数据竞争。本文从内存模型讲起,把常见的错误写法和正确写法对齐。

一、为什么 count++ 不安全

count++ 是读改写三步:加载、加一、存储。两个 goroutine 交错就会出现更新丢失。加锁能解决,但热点上锁的代价(cache line 争用、调度)往往大于计算本身。

atomic 提供的是硬件级原子指令(x86 上是带 lock 前缀的指令),保证单个操作的不可分割。但要理解它的边界:原子性只覆盖单个操作,不覆盖多个操作之间的组合。

var hits int64

func badHandler() {
    hits++                              // 数据竞争,go test -race 直接报
}

func goodHandler() {
    atomic.AddInt64(&hits, 1)           // 累加本身原子
}

二、Check-Then-Act 的经典陷阱

var flag int32

func enable() {
    if atomic.LoadInt32(&flag) == 0 {    // 检查
        atomic.StoreInt32(&flag, 1)      // 行动:两步之间另一个 goroutine 可能已改
    }
}

上例语义是"仅首次",但实现是错的——两个 goroutine 可能都通过 if。正确写法用 CAS 或 CompareAndSwap 把两步合成一个原子操作:

func enableOnce(f *int32) bool {
    return atomic.CompareAndSwapInt32(f, 0, 1)   // 只有真正完成翻转者返回 true
}

sync.Once 内部就是这么实现的。判据:凡是"先读再判断再写",一律不能拆成独立的 atomic 调用。

三、Go 1.19 的 atomic 类型

新代码优先用具体类型,它们是不可复制类型,能防止把已原子使用的值随手拷贝:

var total atomic.Int64
var running atomic.Bool
var ptr    atomic.Pointer[Config]           // 无锁热更新配置

func reload(c *Config) { ptr.Store(c) }
func current() *Config { return ptr.Load() }

atomic.Pointer[T] 是配置热更新的常用范式:旧值不变,新值整体替换,读者拿到的永远是完整一致的对象。不要用 unsafe.Pointer 手写这类逻辑。

注意 atomic.Int64 含对齐相关的内部字段,放进结构体后不能整体拷贝,需要传指针。

四、64 位对齐的历史包袱

在 32 位平台上,64 位原子操作要求 8 字节对齐,未对齐会 panic。Go 1.19 之后atomic 类型内部已对齐,手写 unsafe.Alignof 的做法只属于老代码:

type Stats struct {
    ReqCount atomic.Int64                 // 新写法无需操心对齐
    ErrCount atomic.Int64

    name  string                            // 非原子字段放在后面
}

同一条 cache line 上放多个热点计数器,会造成伪共享:不同核频繁互相失效同一行。真实收益场景(每核独立计数)用 padding 隔离,或直接选分片计数器。

五、伪共享与分片计数器

高并发下"单个全局计数器"本身就是瓶颈:所有核争同一个 cache line。分片计数把争用局部化:

type shardedCounter struct {
    parts []struct {
        v atomic.Int64
        _ [64 - 8]byte                      // 隔离 cache line
    }
}

func newSharded(n int) *shardedCounter {
    c := &shardedCounter{}
    c.parts = make([]struct {
        v atomic.Int64
        _ [64 - 8]byte
    }, n)
    return c
}

func (c *shardedCounter) Inc() {
    c.parts[int(runtime_goid()%uint64(len(c.parts)))].v.Add(1)
}

func (c *shardedCounter) Sum() (t int64) {
    for i := range c.parts { t += c.parts[i].v.Load() }
    return
}

判断是否需要它只能靠数据:go test -bench -benchmem 配合 perf c2c(看 cache line contention)。多数服务的指标 QPS 用一个 atomic.Int64 就足够,未测就上分片是过早优化。

六、内存序与 happens-before

原子操作除了"不撕裂",还提供顺序保证。Go 的内存模型规定:对同一个 atomic 变量的操作构成一个全序,并通过这个序推导 happens-before。

var ready atomic.Bool
var payload string

func writer() {
    payload = "okgoes"                // (1)
    ready.Store(true)                 // (2) 释放语义
}

func reader() {
    if ready.Load() {                 // (3) 获取语义
        use(payload)                  // 能看到 payload = "okgoes"
    }
}

(1)happens-before(2),(3) 读到 true 意味着 (1) 的效果对 (3) 之后的代码可见。这正是"发布—获取"模型,也是无锁初始化与双重检查的基础。

反过来,若 ready 用普通 bool 而非 atomic,编译器与 CPU 都可能重排,reader 可能读到 true 却看到空的 payload。这就是必须用原子或内存屏障的原因。

七、什么时候别用 atomic

三条判据:

  1. 多字段需要一起保持一致。三个字段必须互相自洽,用一把锁,或者打包成一个结构体整体替换(atomic.Pointer[T]),别拆成三个原子变量;
  2. 需要复合读改写。CAS 重试循环逻辑复杂且容易漏分支,这种场景 channel 或 mutex 通常更清晰更快;
  3. 性能不是问题。加锁的可读性与可维护性收益更高。atomic 是性能手段,不是风格偏好。

用 go test -race 覆盖并发路径,是这些代码上线前唯一的保险——数据竞争是未定义行为,"跑起来看着对"完全不能作为判据。

小结

无锁编程的三块地基:单操作交给 atomic、复合逻辑交给 CAS 或锁、跨变量的可见性靠 happens-before(发布获取)、热点计数器才考虑分片与 padding。写完后必须用 -race 覆盖率兜底,用 benchmark 和 perf c2c 证明收益,而不是凭直觉。Go 1.19 之后的 atomic.Int64/atomic.Pointer[T] 已经消掉大部分对齐陷阱,新代码应默认用这一组类型。

读完了?给作者一点支持

本文采用 CC BY 4.0 协议转载 · © 打工人

评论(0)

登录 后参与评论。

  • 还没有评论,来抢沙发。

🔗 相关文章