并发计数不是 count++ 那么简单
多个 goroutine 同时修改一个变量时,普通 count++ 不是安全操作。它看起来是一行,实际包含读取、加一、写回多个步骤。两个 goroutine 交错执行时,更新可能丢失。Go 的 race detector 也会报告数据竞争。
解决共享状态有多种方式:用 channel 汇总,用 sync.Mutex 加锁,用 sync/atomic 做原子操作。atomic 适合非常简单的共享数值或标志位,比如请求计数、开关状态、轻量指标。它不适合表达复杂业务不变量。初学者要先学会判断边界:能用锁写清楚的,别为了“高性能”硬上 atomic。
这篇文章用请求计数做例子,讲原子操作的入门用法。
一个错误的计数器
type Counter struct {
value int64
}
func (c *Counter) Inc() {
c.value++
}
func (c *Counter) Value() int64 {
return c.value
}
并发调用 Inc 会产生数据竞争。运行:
go test -race ./...
如果测试覆盖到并发路径,race detector 会提醒你。
使用 atomic.Int64
现代 Go 提供了更易用的 atomic 类型:
type Counter struct {
value atomic.Int64
}
func (c *Counter) Inc() {
c.value.Add(1)
}
func (c *Counter) Value() int64 {
return c.value.Load()
}
使用:
var counter Counter
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Inc()
}()
}
wg.Wait()
fmt.Println(counter.Value())
Add 和 Load 都是原子操作。多个 goroutine 同时调用不会产生普通数据竞争。
用在 HTTP 指标里
type Metrics struct {
requests atomic.Int64
}
func (m *Metrics) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.requests.Add(1)
next.ServeHTTP(w, r)
})
}
func (m *Metrics) Handler(w http.ResponseWriter, r *http.Request) {
value := m.requests.Load()
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "requests %d\n", value)
}
注册:
metrics := &Metrics{}
mux.Handle("/api", metrics.Middleware(apiHandler))
mux.HandleFunc("/metrics", metrics.Handler)
这是 atomic 适合的场景:一个独立计数值,写入和读取都很简单。
什么时候用 Mutex 更好
如果状态不止一个字段,并且它们之间有关系,用锁更清楚:
type Account struct {
mu sync.Mutex
balance int64
updated time.Time
}
func (a *Account) Deposit(cents int64) {
a.mu.Lock()
defer a.mu.Unlock()
a.balance += cents
a.updated = time.Now()
}
如果你用多个 atomic 字段分别更新,很可能在某个瞬间读到不一致状态。锁能保护一组字段的不变量。
经验很简单:单个计数器、布尔开关、独立指标可以考虑 atomic;多个字段组合、业务规则、复杂状态,优先用 mutex 或 channel。
不要复制 atomic 值
包含 atomic 字段的结构体不应该在使用后被复制:
counter := Counter{}
counter.Inc()
copyCounter := counter // 不要这样做
复制会让状态语义变得混乱。通常把这类对象通过指针传递:
func NewCounter() *Counter {
return &Counter{}
}
这和包含 sync.Mutex 的结构体类似,使用后都不应该复制。
用 atomic.Bool 做开关
除了计数,原子布尔值也很常见。比如服务正在关闭时,拒绝新任务:
type Worker struct {
closing atomic.Bool
}
func (w *Worker) Close() {
w.closing.Store(true)
}
func (w *Worker) Submit(job Job) error {
if w.closing.Load() {
return fmt.Errorf("worker is closing")
}
// enqueue job
return nil
}
这个场景里状态很简单:开或关。用 atomic 比加一把锁更轻,也很清楚。
但如果关闭时还要同时修改队列、等待任务、更新多个字段,就应该回到 mutex 或 channel。atomic 只适合独立状态,不适合复杂生命周期管理。越是并发代码,越不要为了少写几行锁而牺牲可读性。
小结
sync/atomic 适合处理简单共享状态,比如原子计数、开关和轻量指标。atomic.Int64 这类类型比旧式函数更易读。并发代码里普通 count++ 不安全,应该用 atomic、mutex、channel 等同步方式。
不要把 atomic 当成万能性能工具。状态一复杂,锁往往更清楚、更安全。并发代码的第一目标是正确,其次才是性能。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。