Go 的 map 为什么并发读写会 panic?sync.Map 什么时候用?

#Go#面试八股

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 数)

两个容易忽略的点:

  1. Range 不是快照:遍历期间其他 goroutine 的写入可能看不到,也可能被看到;
  2. 没有 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 更合适。

小结

记住三句话:

  1. map 的并发检测是致命的、不可 recover 的——它会直接杀掉进程,而不是返回错误;
  2. sync.Map 不是"更好的 map",它是为"读多写少 + key 稳定"特化的结构,普通场景用 RWMutex + map 更省心;
  3. 别在进程内解决跨进程问题——需要共享就交给 Redis,map 只做本地缓存。
笔记加载中…