编程 加了 Mutex 还 panic:Go map 并发迭代与写冲突

2026-08-27 21:06:48 views 5

加了 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 方案更干净:遍历只读,修改推迟到迭代结束,彻底绕开锁。

复制全文 生成海报 Go 并发 map 数据竞争

推荐文章

程序员茄子在线接单