在 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,通常比单纯追求“无锁”更加重要。