并发计数是 Go 服务里很常见的小需求:请求数、错误数、当前连接数、后台任务数量。用 mutex 可以实现,但对单个整数计数来说,sync/atomic 更轻便。较新的 Go 版本提供了类型化原子值,比如 atomic.Int64,比老式函数更好读。
本文讲 atomic 的基本用法和边界。重点是:它适合简单独立变量,不适合复杂业务状态。
请求计数
type Metrics struct {
requests atomic.Int64
errors atomic.Int64
}
func (m *Metrics) IncRequest() {
m.requests.Add(1)
}
func (m *Metrics) IncError() {
m.errors.Add(1)
}
func (m *Metrics) Snapshot() (requests int64, errors int64) {
return m.requests.Load(), m.errors.Load()
}
HTTP 中间件里使用:
func MetricsMiddleware(m *Metrics, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.IncRequest()
next.ServeHTTP(w, r)
})
}
多个 goroutine 同时 Add 是安全的。读的时候用 Load,不要直接访问内部字段。
当前连接数
连接数需要进入时加一,离开时减一:
func TrackInFlight(m *Metrics, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.inFlight.Add(1)
defer m.inFlight.Add(-1)
next.ServeHTTP(w, r)
})
}
defer 能保证 handler 提前返回时也会减一。计数类指标最怕只加不减,最后数字越来越假。
原子布尔开关
type Switch struct {
enabled atomic.Bool
}
func (s *Switch) Enable() {
s.enabled.Store(true)
}
func (s *Switch) Enabled() bool {
return s.enabled.Load()
}
这种开关适合运行时简单控制,比如临时关闭某个非核心功能。复杂配置仍然更适合用不可变配置快照和 atomic.Value,不要把很多 bool 分散在各处。
atomic 不适合复合状态
假设你有两个值必须一致更新:
type Window struct {
start atomic.Int64
end atomic.Int64
}
如果先 Store start,再 Store end,读者可能看到新 start 和旧 end 的组合。对这种复合状态,mutex 更清楚:
type WindowStore struct {
mu sync.RWMutex
start int64
end int64
}
func (s *WindowStore) Set(start, end int64) {
s.mu.Lock()
defer s.mu.Unlock()
s.start = start
s.end = end
}
atomic 是底层工具,不要用它拼复杂业务不变量。只要有多个字段要一起变化,优先考虑锁。
测试并发计数
func TestMetricsConcurrent(t *testing.T) {
var m Metrics
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
m.IncRequest()
}()
}
wg.Wait()
requests, _ := m.Snapshot()
if requests != 100 {
t.Fatalf("requests = %d", requests)
}
}
配合 go test -race 可以确认没有普通数据竞争。atomic 操作本身不会被 race detector 报错。
可读性很重要
不要为了省一把锁,把代码写成一堆 CompareAndSwap 循环。CAS 很强大,但也更难读。比如简单计数用 Add,简单读取用 Load,简单设置用 Store。需要复杂 CAS 时,先问问 mutex 是否更容易维护。
Go 不是鼓励到处写无锁代码。标准库提供 atomic,是为了那些确实适合原子操作的小状态。
CompareAndSwap 的入门场景
有时你希望从 false 切到 true,并且只有第一个 goroutine 成功。比如某个后台任务只能启动一次:
type Starter struct {
started atomic.Bool
}
func (s *Starter) Start() bool {
if !s.started.CompareAndSwap(false, true) {
return false
}
go runBackgroundLoop()
return true
}
CompareAndSwap 表示“当前值等于旧值时才替换成新值”。它适合非常小的状态转换。只要状态转换涉及多个字段、错误回滚或资源创建,mutex 往往更清楚。
atomic.Value 保存快照
除了数字和布尔值,atomic.Value 可以保存整个配置快照:
type ConfigHolder struct {
value atomic.Value // stores Config
}
func NewConfigHolder(cfg Config) *ConfigHolder {
h := &ConfigHolder{}
h.value.Store(cfg)
return h
}
func (h *ConfigHolder) Get() Config {
return h.value.Load().(Config)
}
这种模式适合读多写少的不可变配置。写入时替换整个 Config,读取时拿到一个快照。不要 Store 不同具体类型,否则会 panic。也不要拿到快照后修改里面共享的 map 或 slice。
不要混用普通读写
如果一个字段用 atomic 写,就应该也用 atomic 读。不要一边 Add,一边普通读取内部值。混用会形成数据竞争,也破坏代码语义。把原子字段设为未导出,并提供方法,是比较稳的做法。
指标读取不是强一致快照
如果你同时读取多个 atomic 计数:
requests := m.requests.Load()
errors := m.errors.Load()
这两个值不一定来自完全同一时刻。对指标来说通常没问题,因为指标本来就是近似观察。但如果业务逻辑依赖多个值之间的一致关系,就不能这么写。比如“余额”和“冻结金额”必须一致,应该放在同一把锁或同一个事务里。
这也是 atomic 的重要边界:它提供单个变量的原子操作,不自动提供跨变量事务。初学者最容易在这里过度使用 atomic。
避免复制包含 atomic 的结构体
和 mutex 类似,包含 atomic 字段的结构体也不应该随意复制。复制后两个结构体各自有一份计数,看起来像同一个指标,实际已经分叉。通常用指针传递:
func NewMetrics() *Metrics {
return &Metrics{}
}
把 Metrics 注入中间件和服务时传 *Metrics,不要按值传递。这个习惯能减少很多隐蔽问题。
小结
sync/atomic 适合简单、独立的并发状态,比如计数器、当前请求数、布尔开关。类型化的 atomic.Int64、atomic.Bool 让代码更直观。使用时通过 Add、Load、Store 操作,不要绕过原子字段直接读写。
如果多个字段需要保持一致,或者操作有复杂业务语义,用 mutex 更清楚。并发代码的目标是正确和可维护,不是看起来“无锁”。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。