Go 的 map 被并发写时,为什么连 recover 都救不了
先看一段必崩的代码
1package main
2
3import "sync"
4
5func main() {
6 m := map[int]int{}
7 var wg sync.WaitGroup
8
9 for id := 0; id < 2; id++ {
10 wg.Add(1)
11 go func(id int) {
12 defer wg.Done()
13 for i := 0; i < 1_000_000; i++ {
14 m[i] = id
15 }
16 }(id)
17 }
18 wg.Wait()
19}1go run main.go
2# fatal error: concurrent map writes
3# ...
4# exit status 2很多人第一反应是加个 recover:
1defer func() {
2 if r := recover(); r != nil {
3 log.Println("recovered:", r)
4 }
5}()结果进程照样退出,日志里也不会出现 recovered。这不是 recover 写错了,而是这个错误根本不在 panic 体系里。
fatal error 不是 panic
Go 运行时在写 map 时会先给 hmap.flags 置上 hashWriting 标志位。如果另一个 goroutine 此时也在写,或者正在读,运行时就调用 runtime.throw 直接终止进程:
concurrent map writes:两个写操作撞上concurrent map read and map write:读和写撞上,遍历也算读
throw 走的是 fatalthrow,不展开栈,不执行 defer,直接以状态码 2 退出。也就是说,recover 没有机会介入。
更需要注意的是,这种检测是尽力而为的:它只在两次操作恰好落在标志位的窗口内才会触发。程序跑一万次没崩,不代表没有数据竞争,只代表这次没撞上。反过来,一旦线上真的崩了,堆栈里只会打印所有 goroutine 的状态,很难还原当时的业务上下文。
从 Go 1.24 起,map 的内部实现换成了 Swiss table,检测代码的位置变了,但“并发写直接 fatal”这条规则没有变。
“我是值传递,应该没关系吧”
没关系是错觉。map 变量本身只是一个指向 hmap 的指针,函数传参复制的是这个指针:
1func fill(m map[string]int) {
2 m["a"] = 1 // 改的是调用方的 map
3}同理,把 map 放进结构体再复制结构体,两个副本仍然共享同一份底层数据。加锁必须加在所有访问路径上,而不是加在“我以为独立”的那份副本上。
四种改法,按场景选
| 场景 | 方案 | 注意点 |
|---|---|---|
| 读写都很频繁,逻辑复杂 | 单 goroutine 独占,其他 goroutine 用 channel 提交操作 | 吞吐受单点限制,但最容易保证正确 |
| 读多写少,临界区短 | sync.RWMutex | 写锁会阻塞后续读锁,别在锁里做 IO |
| key 基本只写一次、之后反复读 | sync.Map | 不适合“先 Load 再 Store”的读改写 |
| 写多、key 分散 | 分片锁 | 按 hash 把 key 分到 N 个 map,各自加锁 |
最常见的写法是给 map 包一层:
1type Counter struct {
2 mu sync.Mutex
3 m map[string]int
4}
5
6func NewCounter() *Counter {
7 return &Counter{m: make(map[string]int)}
8}
9
10func (c *Counter) Inc(key string) {
11 c.mu.Lock()
12 defer c.mu.Unlock()
13 c.m[key]++
14}
15
16func (c *Counter) Get(key string) int {
17 c.mu.Lock()
18 defer c.mu.Unlock()
19 return c.m[key]
20}两个细节:
Counter必须用指针接收者,sync.Mutex一旦使用就不能复制。- 不要提供
func (c *Counter) Map() map[string]int这种返回内部 map 的方法,否则锁就白加了。
如果读远多于写,可以把 Get 换成 RLock/RUnlock。但 RWMutex 并不比 Mutex 快,它多了一层原子操作,临界区只有几行代码时,收益往往抵不过开销。先用 Mutex,压测确认读锁竞争是瓶颈再换。
sync.Map 也不是万能替代品:
1var m sync.Map
2
3m.Store("a", 1)
4v, ok := m.Load("a")
5
6// 错误:Load 和 Store 之间不是原子的
7if _, ok := m.Load("a"); !ok {
8 m.Store("a", 1)
9}
10
11// 正确:用 LoadOrStore
12actual, loaded := m.LoadOrStore("a", 1)官方文档明确说它适合两类场景:key 只写一次、读很多次;或者多个 goroutine 读写互不相交的 key 集合。其余情况,普通的 map 加锁通常更清楚,也更快。
锁保护的是 map,不是 map 里的东西
这是比“忘了加锁”更隐蔽的一类 bug:
1type Cache struct {
2 mu sync.Mutex
3 m map[string][]int
4}
5
6func (c *Cache) Append(k string, v int) {
7 c.mu.Lock()
8 defer c.mu.Unlock()
9 c.m[k] = append(c.m[k], v) // 读改写都在锁内,安全
10}
11
12func (c *Cache) Get(k string) []int {
13 c.mu.Lock()
14 defer c.mu.Unlock()
15 return c.m[k] // 危险:把底层数组交出去了
16}调用方拿到切片后继续 append 或改元素,就绕过了锁。要么返回副本:
1func (c *Cache) Get(k string) []int {
2 c.mu.Lock()
3 defer c.mu.Unlock()
4 out := make([]int, len(c.m[k]))
5 copy(out, c.m[k])
6 return out
7}要么在文档里写清楚“返回值只读,且生命周期不超过下一次写”。存指针、存结构体也是同理:map 里放的是 *Item,锁只保证指针读写安全,item.Count++ 依然要另外同步。
把它揪出来的办法
-race 基于 happens-before 分析,能报出“没有同步的并发访问”,比等 fatal error 可靠得多。但它只能发现实际发生过的冲突,所以测试里必须真的并发:用 sync.WaitGroup 卡住所有 goroutine 再同时开跑,或者直接用 t.Parallel()。
线上不方便开 race 时,至少做两件事:
- 在预发环境用
go build -race跑一轮压测; - 把所有全局 map 列出来,逐个确认写入方是不是只有一个。
常见的隐藏来源包括:HTTP handler 里改全局缓存、定时任务和请求处理同时更新同一张表、用 map 做去重集合却在多个 goroutine 里 m[k] = struct{}{}、以及 for k := range m 遍历时别处仍在写。
检查清单
- 全局 map 是否有明确的“所有者”?没有就加锁或收归单 goroutine。
- 每个
m[k] = v、delete(m, k)、range m是否都在同一把锁里? - 有没有把 map 或其内部切片、指针泄露到锁外?
sync.Map的读改写是否用了LoadOrStore、CompareAndSwap这类原子操作?- CI 是否跑了
go test -race?
map 的并发问题不会给你“偶尔算错”的温柔提示,它直接让进程消失。与其在崩溃后对着 fatal error 猜,不如在写第一行 m[k] = v 时就想清楚:这把锁,谁负责拿。