Golang sync/atomic

在 Go 的并发程序中,多个 goroutine 同时读写共享变量是非常常见的场景。例如统计请求数量、维护连接数、实现限流器、更新状态标志等。如果只是简单地执行 counter++,即使变量本身是一个 int64,也不能保证并发安全。

Go 标准库提供了 sync/atomic,用于执行原子读、写、加减、交换和 CAS(Compare-And-Swap)操作。与 sync.Mutex 相比,atomic 更适合保护单个简单变量,在高并发、低临界区的场景下可以减少锁竞争。

1. 为什么 counter++ 不是原子操作

下面的代码看起来非常简单:

var counter int64

func add() {
    counter++
}

但 counter++ 实际上包含“读取 → 加一 → 写回”三个步骤。

如果两个 goroutine 同时执行:

Goroutine A: 读取 counter = 10
Goroutine B: 读取 counter = 10
Goroutine A: 写入 11
Goroutine B: 写入 11

最终结果可能只有 11,而不是预期的 12。

使用 atomic 可以将这个操作变成不可分割的原子操作:

package main

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

func main() {
    var counter atomic.Int64

    var wg sync.WaitGroup
    for range 100 {
        wg.Go(func() {
            for range 1000 {
                counter.Add(1)
            }
        })
    }

    wg.Wait()

    fmt.Println(counter.Load())
}

这里使用的是 Go 新版 API 提供的类型化原子类型 atomic.Int64。相比早期的 atomic.AddInt64(&counter, 1),这种写法更直观,也减少了传递指针时误用普通变量的可能性。

2. atomic 不只是做计数器

sync/atomic 提供的能力主要包括:

操作 作用 常见场景
Load 原子读取 读取状态、计数器
Store 原子写入 更新开关、状态
Add 原子加减 请求数、连接数
Swap 交换旧值和新值 状态切换
CompareAndSwap CAS 无锁状态机、竞争更新
And() / Or() 原子按位与 / 或 -

例如,一个服务需要维护运行状态:

type Server struct {
    running atomic.Bool
}

func (s *Server) Start() {
    if s.running.CompareAndSwap(false, true) {
        fmt.Println("server started")
    }
}

func (s *Server) Stop() {
    if s.running.Swap(false) {
        fmt.Println("server stopped")
    }
}

CompareAndSwap(false, true) 的含义是:只有当前值仍然是 false,才将它修改为 true。

因此,即使多个 goroutine 同时调用 Start(),也只有一个 goroutine 能成功完成状态切换。

3. CAS 是 atomic 的核心能力

CAS 是并发编程中非常重要的一种同步原语:

if 当前值 == 旧值:
    修改为新值
else:
    修改失败

例如实现一个简单的并发序列号:

type Sequence struct {
    value atomic.Uint64
}

func (s *Sequence) Next() uint64 {
    for {
        old := s.value.Load()
        next := old + 1

        if s.value.CompareAndSwap(old, next) {
            return next
        }
    }
}

如果多个 goroutine 同时调用 Next(),其中一个 goroutine 修改成功后,其他 goroutine 的 CAS 就会失败,然后重新读取最新值并重试。

这种模式称为 CAS Loop。

不过,CAS 并不是“无条件优于 Mutex”。当竞争激烈、临界区复杂或者重试次数较多时,CAS 自旋可能产生额外 CPU 开销,此时 Mutex 往往更加合适。

4. atomic 与 Mutex 怎么选择

可以把两者简单理解为:

atomic → 保护一个简单的共享状态
Mutex  → 保护一组需要保持一致的数据

例如统计请求数量:

var requests atomic.Uint64

func handle() {
    requests.Add(1)
}

非常适合 atomic。

但如果需要同时修改多个字段:

type User struct {
    Balance int64
    Status  string
}

要求 Balance 和 Status 始终保持一致,那么使用 Mutex 通常更加合理:

type User struct {
    mu      sync.Mutex
    Balance int64
    Status  string
}

func (u *User) Update(balance int64, status string) {
    u.mu.Lock()
    defer u.mu.Unlock()

    u.Balance = balance
    u.Status = status
}

因为 atomic 只能保证单次原子操作,而业务层面的“一组操作必须同时完成”属于更高层次的一致性要求。

5. atomic 也解决内存可见性问题

atomic 的价值并不仅仅是避免 counter++ 的数据竞争。

并发程序中,一个 goroutine 对共享数据的修改,什么时候能够被另一个 goroutine可靠观察到,也是非常重要的问题。

例如:

type Config struct {
    Enabled bool
}

var config atomic.Pointer[Config]

func setConfig(c *Config) {
    config.Store(c)
}

func getConfig() *Config {
    return config.Load()
}

这种模式特别适合配置热更新、全局只读配置等场景。

更新配置时创建新的对象,然后通过 Store 一次性替换指针:

config.Store(&Config{
    Enabled: true,
})

读取方通过 Load() 获取当前配置,不需要加锁。

这实际上是一种非常实用的“不可变对象 + 原子指针”设计。

6. 使用 atomic 时最容易犯的错误

第一,不要只对其中一个操作使用 atomic,而把相关操作继续用普通读写混合起来。

例如:

counter.Add(1)

fmt.Println(counterValue) // 错误思路

如果共享变量已经采用 atomic 管理,就应该通过 Load() 获取。

第二,不要因为 atomic 很快,就把复杂业务逻辑全部改成 CAS。

例如库存扣减、账户余额更新等操作通常涉及多个字段和业务校验。这类场景使用 Mutex、数据库事务或者其他更高层同步机制,往往更清晰。

第三,要注意 atomic 类型的复制问题。像 atomic.Int64、atomic.Pointer 等类型不应该在第一次使用后被复制。实际工程中通常应该通过指针传递包含 atomic 字段的对象。

总结

sync/atomic 最适合解决的是“小而明确”的并发状态问题:计数器、状态标志、序列号、指针替换以及 CAS 状态机。

工程实践中可以遵循一个简单原则:

单个变量的原子状态 → atomic
多个变量的一致性 → Mutex
跨 goroutine 的任务传递 → channel
跨进程的数据一致性 → 数据库 / Redis 等外部协调机制

不要把 atomic 理解成 Mutex 的“高性能版本”。它们解决的是不同层次的问题。atomic 的优势在于操作粒度非常小、没有传统锁的临界区管理开销;而 Mutex 的优势则是能够清晰地表达复杂临界区和多变量一致性。

对于 Go 服务端开发而言,先明确“我要保护的到底是什么”,再决定使用 atomic、Mutex 还是 channel,通常比单纯追求“无锁”更加重要。

如果你有魔法,你可以看到一个评论框~