从一次线上 panic 说起
很多 Go 项目跑着跑着突然崩溃,日志里出现 fatal error: concurrent map writes,尤其是在推送、统计、缓存加载这类异步任务比较多的服务里。排查到最后,多半是多个 goroutine 同时写了一个全局 map,而写代码的人没有做任何并发控制。

这是我最早意识到 Go sync 包重要性的时刻。Go 的并发模型确实让开发效率提升了不少,但并发带来的共享数据安全问题,并不会因为语言特性而自动消失。sync 包里的几个基础原语,是解决这些问题的第一道防线。
很多人喜欢说“不要通过共享内存来通信,而要通过通信来共享内存”。这句话本身没问题,但落到真实工程里,你会发现 sync.Mutex 和 sync.WaitGroup 的使用频率远高于 channel。原因很简单:并不是所有共享状态都适合用 channel 来表达,尤其是缓存、配置、计数器这类需要频繁读写的场景。
Mutex:最基础也最容易被误用的同步原语
sync.Mutex 的核心功能是保证同一时刻只有一个 goroutine 能够进入临界区。它的用法看起来很简单,但真正决定代码质量的是“锁的范围”和“锁的粒度”。
先看一个最常见的使用场景:保护并发环境下的 map。比如一个内存缓存:
var (
cache = make(map[string]*item)
cacheMu sync.Mutex
)
func get(key string) *item {
cacheMu.Lock()
defer cacheMu.Unlock()
return cache[key]
}
func set(key string, val *item) {
cacheMu.Lock()
defer cacheMu.Unlock()
cache[key] = val
}
这里用 defer 解锁是为了保证即使函数中出现了 panic,锁也能被释放。这种写法在大多数情况下是安全的,但真正的问题往往出现在临界区里做的事情太多。
锁的边界决定了并发性能
很多人习惯在 Lock() 之后做一堆事情,比如调用外部 HTTP 接口、写日志、跑一个复杂的计算。这些操作一旦放进了临界区,性能会急剧下降,因为其他 goroutine 只能干等着。更严重的是,如果临界区里调用了某个函数,而那个函数内部又试图获取同一把锁,就会直接死锁。
一个更合理的做法是:只把真正需要保护的操作放在锁内。比如先读取值,如果缓存不存在,则在锁外执行加载逻辑:
func getOrLoad(key string) (*item, error) {
cacheMu.Lock()
val, ok := cache[key]
cacheMu.Unlock()
if ok {
return val, nil
}
// 锁外执行加载,避免长时间持有锁
val, err := loadFromDB(key)
if err != nil {
return nil, err
}
cacheMu.Lock()
cache[key] = val
cacheMu.Unlock()
return val, nil
}
这种写法把锁的持有时间压缩到了最短,但引入了一个新问题:两个 goroutine 可能同时发现缓存不存在,然后同时去数据库加载,造成重复查询。对于这种情况,有一种更精细的处理方式是使用单飞(singleflight)模式,但那是另一个话题了。至少,先保证锁内不做多余操作,是避免性能劣化的第一步。
锁不是越宽越好,也不是越细越好,而是“刚好保护住共享数据的那一小段操作”最好。
RWMutex:读多写少场景下的更优选择
当读操作远多于写操作时,sync.RWMutex 是比 sync.Mutex 更合适的工具。它允许读锁被多个 goroutine 同时持有,只有写锁才独占访问。这意味着,在一个典型的缓存系统中,大量并发读请求可以并行通过,而不会被互斥锁串行化。
用 RWMutex 改写上面的缓存:
var (
cache = make(map[string]*item)
cacheMu sync.RWMutex
)
func get(key string) *item {
cacheMu.RLock()
defer cacheMu.RUnlock()
return cache[key]
}
func set(key string, val *item) {
cacheMu.Lock()
defer cacheMu.Unlock()
cache[key] = val
}
这段代码看起来和 Mutex 版本很像,但并发行为完全不同。读操作之间不会互相阻塞,只有写操作会独占。对于读多写少的服务,这种优化往往能带来明显的吞吐提升。
RWMutex 不是银弹
但这里有一个常见的误区:RWMutex 并不总是比 Mutex 更快。如果你的场景里写操作也很多,RWMutex 的维护成本其实比 Mutex 更高。因为写锁需要等待所有读锁释放,而读锁又可能被不断到达的读请求阻塞。Go 1.8 之前,RWMutex 的写锁等待时,新来的读请求还会继续抢占读锁,导致写锁饥饿。Go 1.8 之后做了一些调整,写锁等待时新的读锁也会被阻塞,但这反而意味着读操作的并发性在某些情况下会突然变差。
所以,选择 Mutex 还是 RWMutex,取决于你对读写比例的判断。一个经验法则是:如果写操作占比超过 20% 到 30%,RWMutex 带来的收益就很有限了。不如直接用 Mutex,简单可控。
WaitGroup:优雅地等待一组 goroutine 完成
sync.WaitGroup 用来等待一组 goroutine 全部结束。它在批量任务、并发拉取、优雅关闭等场景中非常常用。基本用法很简单:
var wg sync.WaitGroup
for _, task := range tasks {
wg.Add(1)
go func(t task) {
defer wg.Done()
process(t)
}(task)
}
wg.Wait()
这里有几个非常容易被忽视的细节。
Add 必须在 Wait 之前被执行
最经典的错误是把 Add 写到了 goroutine 内部。也就是说,在启动 goroutine 的循环里没有调用 Add,而是在 goroutine 内部才开始 Add。这会导致一个问题:主 goroutine 可能已经执行到 Wait(),此时计数为 0,直接返回,而实际任务还没有执行完。
正确做法是:在启动 goroutine 之前,也就是主 goroutine 中,完成所有 Add 调用。这保证了计数器的增加一定发生在 Wait 之前。
WaitGroup 不能被复制
sync.WaitGroup 内部维护了一个计数器和一个等待队列,这些状态依赖内部共享的内存。如果你把它作为参数传递、或者赋值给另一个变量,就会复制出一份独立的状态,导致 Done 和 Wait 操作的不是同一个计数器,程序会 panic 或者卡死。
需要传递时,一定要用指针:
func runTasks(wg *sync.WaitGroup, tasks []Task) {
for _, t := range tasks {
wg.Add(1)
go func(t Task) {
defer wg.Done()
t.Execute()
}(t)
}
}
进阶:当 WaitGroup 不够用时
如果每个 goroutine 都可能返回 error,并且你需要拿到第一个错误信号,sync.WaitGroup 就不够用了。这时通常会考虑 golang.org/x/sync/errgroup,它是 WaitGroup 的扩展版,在等待所有 goroutine 完成的同时,还能收集第一个非 nil 的 error。
一个典型的用法是并发拉取多个服务的数据:
g, ctx := errgroup.WithContext(context.Background())
for _, svc := range services {
svc := svc
g.Go(func() error {
return fetchData(ctx, svc)
})
}
if err := g.Wait(); err != nil {
return nil, err
}
这里 errgroup.WithContext 还会在第一个 goroutine 返回错误时取消上下文,避免其他 goroutine 做无用功。这种机制在很多微服务调用场景中非常实用。
三个原语的选型对比
| 原语 | 适用场景 | 核心特点 | 常见风险 |
|---|---|---|---|
| sync.Mutex | 读写都频繁,或临界区操作简单 | 完全互斥,实现简单 | 锁内做耗时操作导致性能劣化 |
| sync.RWMutex | 读多写少,比如缓存、配置读取 | 读锁共享,写锁独占 | 写多场景下维护成本高 |
| sync.WaitGroup | 等待一组并发任务全部完成 | 计数器机制,支持动态增加 | Add 放错位置或复制 WaitGroup |
这张表列出的只是最直观的选型参考。实际工程里,你还要考虑锁的竞争频率、临界区大小、goroutine 数量等因素。没有一个原语是万能的,关键是根据访问模式选择最匹配的工具。
几个容易踩的坑
- 锁的复制问题:Mutex、RWMutex 和 WaitGroup 都是不可复制类型,一旦复制就会失去同步能力。Go 的 vet 工具可以检测部分复制问题,建议在 CI 中加入。
- 死锁:最常见的死锁场景是在持有一把锁的同时,去获取另一把锁,而其他 goroutine 持有后者又去获取前者。这种循环等待很难排查,最好保持锁的顺序一致,或者尽量只持有一把锁。
- 锁内调用外部函数:外部函数可能是耗时的网络调用,也可能是无意中触发同一个锁的递归调用。这会让锁的持有时间不可控。
- 滥用 RWMutex:写操作多的情况下,RWMutex 的锁开销和竞争管理可能比普通 Mutex 更差,还会带来写饥饿问题。
落地建议:从正确使用到优雅设计
如果你刚开始在一个 Go 项目里使用 sync 包,我的建议是先不要追求花哨的并发模式。把最基本的 Mutex 用对:锁的边界尽量小、不要在锁内做耗时操作、不要复制锁。
当你的并发读写出现性能瓶颈时,再考虑引入 RWMutex。但引入前,最好先测量一下读写比例。可以用 go pprof 查看锁竞争情况,确认瓶颈确实在锁上,再动手优化。
对于 WaitGroup,养成一个习惯:Add 永远放在 goroutine 启动之前,Done 永远通过 defer 调用。如果 goroutine 里可能 panic,这还能避免计数不匹配的问题。
最后,如果你的程序需要处理错误传播、取消信号、超时控制,直接用 errgroup 而不是自己用 WaitGroup + channel 拼凑。标准库之外的 x/sync 扩展包,是经过生产环境验证的成熟方案。
总结
Go 的并发原语看起来都很简单,但真正用好的关键在于理解它们解决的是哪一类访问模式。Mutex 解决的是互斥,RWMutex 解决的是读多写少场景下的共享,WaitGroup 解决的是任务编排。三者之间不是替代关系,而是互补关系。
回顾文章开头提到的 concurrent map writes panic,如果当初写代码的人对 sync 包有清晰的理解,在动手前就想清楚“这个 map 会被哪些 goroutine 访问”,这个问题本来是可以完全避免的。并发编程的难点从来不是语法,而是对共享资源访问路径的深刻认识。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/465/