Go 的 map 为什么并发读写会 panic?sync.Map 什么时候用?
Go 的 map 不是并发安全的,但它的失败方式很特别:不一定每次都 panic,一旦 panic 就无法恢复
(fatal error: concurrent map writes),recover 也拦不住。
1. 为什么是 fatal error 而不是普通 panic
普通 panic 可以 recover,而 map 的并发写检测走的是 throw(运行时致命错误),
因为它发生在编译器插入的写屏障检查里,那一刻 map 的内部结构可能已经损坏,
继续跑只会产生更隐蔽的数据损坏,所以运行时选择直接终止进程。
触发点(runtime/map.go 的 mapassign / mapaccess):
if h.flags&hashWriting != 0 {
fatal("concurrent map writes")
}
h.flags |= hashWriting
...
h.flags &^= hashWriting
hashWriting 是个标志位:写入前置位、写完清位。两个 goroutine 同时进入,第二个看到标志位
就被判定为并发写。这是"检测"而不是"防护"——它保证你立刻知道出事了,但不保证安全。
2. 为什么读也可能崩
- 并发写 + 写:必炸(上面的标志位);
- 并发读 + 写:可能炸,也可能读到旧值/半更新状态。最典型的是扩容期间:
map 触发
grow后处于"渐进式搬迁"状态,读写都要判断oldbuckets,此时并发读写很容易 读到不一致的桶指针。
所以官方文档的说法是:并发读是安全的,只要有任意一个 goroutine 在写,就必须同步。
3. 常规解法:自己加锁
type SafeMap[K comparable, V any] struct {
mu sync.RWMutex
m map[K]V
}
func (s *SafeMap[K, V]) Get(k K) (V, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.m[k]
return v, ok
}
func (s *SafeMap[K, V]) Set(k K, v V) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[k] = v
}
这是最常用、最好维护的方案。RWMutex 在读多写少时能显著提升并发度。
4. sync.Map 适合什么
sync.Map 内部用两个 map(read 只读快照 + dirty),读操作大多无锁。
var m sync.Map
m.Store("k", 1)
v, ok := m.Load("k")
m.LoadOrStore("k2", 2)
m.Range(func(k, v any) bool { return true })
它的适用场景非常窄,官方注释写得很清楚:
| 适合 | 不适合 |
|---|---|
| key 集合基本稳定、读远多于写(如配置缓存) | 频繁增删(dirty 会被反复重建) |
| 多个 goroutine 读写不相交的 key(各管各的分片) | 需要"读-改-写"原子操作(要用 CompareAndSwap 或外层锁) |
| 只增不删的注册表(如连接、指标) | 需要 Len()(Go 1.23 前没有,要 Range 数) |
两个容易忽略的点:
Range不是快照:遍历期间其他 goroutine 的写入可能看不到,也可能被看到;- 没有
Len()(1.23 才加):想统计数量只能Range,代价是 O(n)。
5. 分片锁:高并发下的折中
当单把锁成为瓶颈时,按 key 哈希分片:
const shards = 32
type ShardedMap[K comparable, V any] struct {
mu [shards]sync.RWMutex
m [shards]map[K]V
}
func (s *ShardedMap[K, V]) idx(k K) uint32 {
// 简单哈希即可;K 需要能转成 hash(或用 fnv + fmt)
return uint32(fnv32(k)) % shards
}
分片数取 2 的幂,锁竞争降到 1/32。代价是 Range 要遍历所有分片,
且跨分片的原子操作做不了(要一次锁多个分片,小心死锁)。
6. 选择顺序
读多写少、key 稳定 → sync.Map(或 RWMutex + map)
读写均衡、逻辑复杂 → RWMutex + map(最好维护)
QPS 极高、热点分散 → 分片锁
需要原子读改写 → 外层锁,或 CAS 重试(sync.Map.CompareAndSwap)
跨进程共享 → Redis,别在进程内硬扛
7. 一个常被忽略的坑:range 时删除
for k := range m {
delete(m, k) // 单 goroutine 下是允许的
}
单 goroutine 里 delete 是安全的,但并发场景下这就是并发写。
另一个坑是把 map 当"队列"用:range 期间增删会让迭代行为变得不可预期,用 slice/channel 更合适。
小结
记住三句话:
- map 的并发检测是致命的、不可 recover 的——它会直接杀掉进程,而不是返回错误;
- sync.Map 不是"更好的 map",它是为"读多写少 + key 稳定"特化的结构,普通场景用
RWMutex + map更省心; - 别在进程内解决跨进程问题——需要共享就交给 Redis,map 只做本地缓存。