学完这篇你能拿到一套可直接套用的判断标准:什么时候该上 sync.Mutex、什么时候换 sync.RWMutex、什么时候用 sync/atomic 就够,以及每种写法最容易踩的坑长什么样。
第一步:先确认到底有没有数据竞争
不是所有并发都要加锁。先看有没有多个 goroutine 同时读写同一块内存,且至少有一个是写。
var counter int
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // 读-改-写三步,非原子
}()
}
wg.Wait()
fmt.Println(counter) // 每次跑结果都不一样
}
验证手段是 Go 自带的竞争检测器,不用装任何东西:
go run -race main.go
go test -race ./...
go build -race
出现 WARNING: DATA RACE 就是实锤。养成在 CI 里跑 -race 的习惯,比事后看随机崩盘省事得多。
注意:-race 会让程序慢 5~20 倍、内存涨几倍,只在测试和排查时开,不要带到生产构建里。
第二步:sync.Mutex 的基本写法
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
几个必须记牢的点:
sync.Mutex 零值即可用,不用 New 也不用初始化。
defer c.mu.Unlock() 紧跟在 Lock() 下一行写,中间不要插别的代码,这样任何 panic 路径都不会漏解锁。
- 方法用指针接收者
*Counter。用值接收者会导致锁复制,等于没锁。
- 读也要加锁。
Value() 不加锁去读 n,同样是数据竞争。
注意:sync.Mutex 不可复制。含锁的结构体不要按值传参、按值返回、放进 slice 里 append、也不要作为 map 的值。go vet 会报 copylocks,别忽略它。
注意:Go 的 Mutex 不可重入。同一个 goroutine 里再 Lock() 一次会直接把自己锁死,报 fatal error: all goroutines are asleep - deadlock!。封装方法时别让 A() 持锁后再调 B() 里也加锁——要么拆出不加锁的内部方法 aLocked(),要么把锁提到调用方。
锁的粒度也要控制:锁里只放内存操作。HTTP 请求、数据库查询、文件读写、time.Sleep 一律挪到锁外,否则并发度直接归零。
第三步:读多写少才用 RWMutex
type Store struct {
mu sync.RWMutex
m map[string]string
}
func (s *Store) Get(k string) (string, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.m[k]
return v, ok
}
func (s *Store) Set(k, v string) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[k] = v
}
RLock 之间可以并行,Lock 独占。但它的开销比普通 Mutex 大,只有在读操作明显多于写(大概 10:1 以上)时才划算。读写各半的场景,Mutex 反而更快。
注意:读锁里不能修改被保护的数据。RLock 期间多个 goroutine 同时进来,改 map 会直接 fatal error: concurrent map writes,这种崩溃连 -race 都救不了。
注意:RWMutex 有写者优先策略,递归加读锁会死锁。goroutine A 拿着 RLock,此时 B 在等 Lock,A 再申请一次 RLock 会被 B 挡住,B 又在等 A 释放——双方互等。加锁路径上不要嵌套调用同组件的其它读方法。
第四步:单纯计数、标志位用原子操作
Go 1.19 起有类型化的原子变量,比 atomic.AddInt64(&x, 1) 好读:
var hits atomic.Int64
hits.Add(1)
fmt.Println(hits.Load())
var stopped atomic.Bool
if stopped.CompareAndSwap(false, true) {
// 抢到了,只有一个 goroutine 会进来
}
还有 atomic.Pointer[T] 做无锁的配置热更新——写侧 Store 新对象,读侧 Load 拿快照,读写都不阻塞。
原子操作适合:单个计数器、单个布尔开关、单个指针的整体替换。它没有锁,不存在忘记解锁的问题,性能也最好。
注意:原子操作只保证「这一个变量」的原子性。两个字段需要保持一致的场景(比如 balance 和 version 一起改),必须用 Mutex。拆成两个 atomic 是错的。
注意:不要混用。一处用 atomic.Add,另一处直接 x++ 裸读裸写,依然是数据竞争,-race 照样报。
注意:atomic.Value 要求每次 Store 的类型完全一致,先存 *Config 又存 Config 会 panic;存 nil 也要走带类型的 nil 指针,别直接 Store(nil)。
第五步:按场景选,别按喜好选
| 场景 | 选择 |
|---|
| 单个计数器、开关、指针快照 | sync/atomic |
| 保护多个字段或一段逻辑 | sync.Mutex |
| 读远多于写(10:1 以上) | sync.RWMutex |
| 只初始化一次 | sync.Once |
| 键空间大且访问分散的缓存 | sync.Map(默认还是先用 Mutex + map) |
写完统一跑一遍 go test -race ./...,把竞争问题在合并前拦住。
小结
- 先确认有竞争再加锁,
-race 是唯一可靠的验证手段。
Mutex 零值可用、不可复制、不可重入;defer Unlock 紧跟 Lock,读也要加锁。
- 锁内不做 IO,只保护临界区。
RWMutex 只在读远多于写时用;读锁里禁止修改数据,禁止递归加读锁。
- 单个变量用原子操作,多字段一致性必须用锁,绝不混用。