加了 Mutex 还 panic:Go map 并发迭代与写冲突
给 map 写操作加了 sync.Mutex,自测没问题,压测和线上却随机崩溃。报错固定是:
concurrent map iteration and map write
复现逻辑:一批用户 ID 先写入 map 标记缓存未命中,再启动多个协程并发回源查询,查询后回填有效数据、删除非法用户 Key。代码大致是这样:
kv := make(map[string]string)
for _, userId := range userIds {
kv[cacheKey(userId)] = ""
}
var mu sync.Mutex
var wg sync.WaitGroup
wg.Add(len(kv))
for key := range kv { // 主协程遍历 map
go func(k string) {
defer wg.Done()
userId := parseUserId(k)
if userId <= 0 {
mu.Lock()
delete(kv, k) // 加锁删除
mu.Unlock()
return
}
data, err := batchQueryFromDB([]int64{userId})
if err != nil {
return
}
if v, ok := data[userId]; ok {
mu.Lock()
kv[k] = v // 加锁回填
mu.Unlock()
}
}(key)
}
wg.Wait()
return kv, nil
所有写操作都加了锁,为什么还会崩?
用 go run -race main.go 跑一遍,能看到冲突点:
==================
WARNING: DATA RACE
Write at 0x00c00012a1e0 by goroutine 8:
main.BatchGetUserBadges.func1() main.go:XX // delete(kv, k)
Previous read at 0x00c00012a1e0 by goroutine 1:
main.BatchGetUserBadges() main.go:XX // for key := range kv
==================
冲突发生在“写”和“读”之间,不是写和写。锁只保证了子协程之间的写操作互斥,主协程的 range 遍历全程无锁。它一边读 map 底层 bucket,子协程一边在锁里删除或赋值,构成数据竞争。
更深一层,Go 对 map 有一条硬性约束:map 处于 range 迭代状态时,禁止任何修改操作。这个约束不区分是否加锁。即便所有写操作都通过 Mutex 串行化,迭代期间一旦有写,运行时同样 panic。因为 map 迭代不复制数据快照,而是持有底层 bucket 指针边遍历边访问;期间插入、删除或扩容会让迭代指针错乱。Go 宁可崩溃,也不返回脏数据。
所以三个必崩场景也顺理成章:并发读加并发写会崩;多协程并发写会崩;迭代期间并发写必崩。普通 Mutex 对此没有帮助。
方案一:channel 收集结果(推荐)
把并发回填改成并发“上报”。遍历阶段只读 key,协程计算结果通过 channel 发给主协程,等所有协程结束后统一回填 map。
results := make(chan cacheResult, len(kv))
wg.Add(len(kv))
for key := range kv {
go func(k string) {
defer wg.Done()
// ... 查询处理得到 value
results <- cacheResult{key: k, val: v}
}(key)
}
wg.Wait()
close(results)
for r := range results {
kv[r.key] = r.val
}
写 map 的动作全部收敛到迭代结束后的单协程里,锁也可以去掉。数据竞争从根源消失。
方案二:RWMutex 读写锁
如果不想调整协程结构,可以用 RWMutex 让“遍历读”和“子协程写”互斥。做法是在主协程 range 前加 mu.RLock(),遍历期间持有读锁;子协程的删除和赋值用 mu.Lock() 保护。
var mu sync.RWMutex
mu.RLock() // 遍历前加读锁
for key := range kv {
go func(k string) {
defer wg.Done()
// ... 查询等耗时操作
mu.Lock()
delete(kv, k) // 或赋值
mu.Unlock()
}(key)
}
mu.RUnlock() // 遍历结束释放读锁,必须早于 wg.Wait()
wg.Wait()
读锁会让所有写锁请求阻塞,迭代期间不可能发生修改;等 range 结束释放读锁,之前等待的写锁才获准执行,此时迭代已完成,不会 panic。注意 mu.RUnlock() 一定要放在 wg.Wait() 之前,否则子协程永远拿不到写锁,程序死锁。
代价是迭代期间所有写操作被挂起。数据量大、写频繁时延迟会积累,只适合小数据量兼容改造。
锁的适用边界
普通 sync.Mutex 不要用在“边遍历边修改”的 map 上。它只能协调写写,协调不了迭代读和写。只要存在一个 goroutine 在裸奔 range,另一个 goroutine 在锁内修改,panic 仍会发生。
RWMutex 也不是万能。它靠阻塞所有写操作来换取安全,如果 map 存量很大、写操作多或对延迟敏感,性能会快速劣化。这时候 channel 方案更干净:遍历只读,修改推迟到迭代结束,彻底绕开锁。