文章目录
- Go map 为什么不能并发读写:fatal error 背后的扩容与渐进搬迁
- 先说结论
- 我遇到的事
- 反直觉一:不崩不代表安全
- 为什么 Go 选择直接崩,而不是内部加锁
- 底层长什么样:hmap 与 bucket
- 什么时候会扩容
- 反直觉二:扩容是"慢慢搬"的,不是一次性搬完
- 还有个坑:删掉的 key 不释放内存
- 三种并发方案怎么选
- 方案一:`sync.RWMutex` + 原生 map(通用首选)
- 方案二:`sync.Map`(别滥用)
- 方案三:分片加锁(高并发写)
- 选型速查
- 排查速查表
- 小结
Go map 为什么不能并发读写:fatal error 背后的扩容与渐进搬迁
摘要:从一次 fatal error: concurrent map read and map write 说起。讲清 Go 为什么选择"直接崩"而不是加锁、map 底层的 hmap 与 bucket 结构、装载因子 6.5 触发的翻倍扩容与 overflow 过多触发的等量扩容,以及最关键的渐进式搬迁机制——为什么搬迁期间并发读写最危险。最后对比 sync.RWMutex + map、sync.Map、分片加锁三种方案的适用边界,并说明为什么 fatal error 无法用 recover 兜底。
标签:go · golang · map · 并发 · sync.Map
专栏:Go 学习笔记
先说结论
- Go 的 map不是并发安全的,并发读写会触发
fatal error,而这个错误无法用recover兜底,进程直接退出。 - 更危险的是:并发读写不一定每次都崩。没崩不代表安全,你可能在读脏数据。
- 底层原因不是"忘了加锁"这么简单,而是 map 存在渐进式扩容搬迁——搬迁期间同一份数据分散在两块数组里,任何并发写都会破坏搬迁进度。
- 三种方案:
sync.RWMutex + map(通用首选)、sync.Map(只适合读多写少且 key 稳定)、分片加锁(高并发写)。
我遇到的事
需求很简单:起 10 个 goroutine 并发往一个 map 里写,主线程读。
funcmain(){m:=make(map[int]int)varwg sync.WaitGroupfori:=0;i<10;i++{wg.Add(1)gofunc(nint){deferwg.Done()forj:=0;j<1000;j++{m[n*1000+j]=j}}(i)}wg.Wait()}跑起来,程序挂了:
fatal error: concurrent map writes goroutine 18 [running]: runtime.throw({0x4c1a2e?, 0x0?}) .../runtime/panic.go:992 +0x71 runtime.mapassign_fast64(...) .../runtime/map_fast64.go:120 +0x3d8 main.main.func1(...)我当时的第一反应是"加个recover兜一下"。然后发现根本兜不住——fatal error和panic是两回事,recover只能捕获panic。这个进程是硬退出的,没有任何挽救余地。
第二反应是"那我小心点,别同时写就行"。然后我把并发数调小、加了点 sleep,程序居然跑通了。于是我以为问题解决了。
这个"以为"才是最危险的。
反直觉一:不崩不代表安全
Go 对 map 并发的检测依赖hmap里的一个标志位hashWriting:写操作开始时置位,结束时清除。如果读操作(mapaccess)发现这个标志被置了,就抛出fatal error。
但这个检测不是每次都能命中——它依赖时序,取决于两个操作是否真的重叠在那一瞬间。所以:
- 并发读写可能崩,也可能不崩;
- 不崩的时候,你可能正读到一个写了一半的值,或者读到一个正在搬迁中的桶里的数据;
- 这种"偶尔读到脏数据"的问题不会让程序挂掉,只会让业务逻辑在半夜出错,比直接崩溃难查十倍。
结论:不要用"没崩"来判断 map 并发是否安全。要判断就用go test -race,race detector 是基于 happens-before 的静态分析,能抓到那些"这次没崩"的隐患。
为什么 Go 选择直接崩,而不是内部加锁
这是设计上的取舍,不是疏忽。
map 的底层实现涉及哈希计算、桶定位、overflow 链表、扩容搬迁。如果内部加一把全局锁:
- 所有单 goroutine 场景(绝大多数情况)都要为用不上的锁付出代价;
- 读操作也要加锁,性能直接砍一大截;
- 加锁粒度选不好,高并发下就是瓶颈。
Go 团队的选择是:把这个决定权交给开发者。你需要并发就用sync包自己控制,不需要就不付代价。想明白了这一点,后面选型就不纠结了。
底层长什么样:hmap 与 bucket
map 的运行时结构是hmap:
typehmapstruct{countint// 元素个数,len(m) 直接读它flagsuint8// 状态标志,hashWriting 就在这Buint8// 桶数量的对数,桶数 = 2^Bnoverflowuint16// overflow 桶的近似数量hash0uint32// 哈希种子,防止哈希碰撞攻击buckets unsafe.Pointer// 当前桶数组oldbuckets unsafe.Pointer// 扩容时的旧桶数组nevacuateuintptr// 搬迁进度:下一个待搬迁的桶编号extra*mapextra// overflow 桶相关}每个桶(bmap)固定装8 个 key-value 对,装满了就用overflow指针链一个新桶:
buckets ├─ bmap[0] 8个槽 → overflow → bmap → nil ├─ bmap[1] 8个槽 ├─ bmap[2] 8个槽 └─ ... 共 2^B 个注意oldbuckets和nevacuate这两个字段——它们是理解并发危险的关键。
什么时候会扩容
两种触发条件:
1. 装载因子超过 6.5 → 翻倍扩容
装载因子 =count / (2^B * 8)。当平均每个桶装的元素超过 6.5 个,说明桶快满了,碰撞概率上升。此时新建2^(B+1)个桶,即容量翻倍。
2. overflow 桶太多 → 等量扩容
有时候元素不多,但反复增删导致 overflow 链表很长、数据很稀疏。这时桶数不变,重新排布一次,让数据更紧凑。
判断逻辑在runtime/map.go里:
if!h.growing()&&(overLoadFactor(h.count+1,h.B)||tooManyOverflowBuckets(h.noverflow,h.B)){hashGrow(t,h)}反直觉二:扩容是"慢慢搬"的,不是一次性搬完
这是整篇文章最值得记住的一点。
如果 map 里有几百万个元素,一次性搬迁会造成明显的卡顿。Go 的做法是渐进式搬迁:
hashGrow只分配新桶数组,把旧桶挂到oldbuckets,设置nevacuate = 0,不搬任何数据;- 之后每次对 map 的写操作(
mapassign)或删除(mapdelete),顺手搬迁 1~2 个桶; - 搬迁完成前,
oldbuckets和buckets同时存在,读操作需要判断目标 key 在旧桶还是新桶; - 全部搬完,
oldbuckets置 nil,扩容结束。
可以通过h.growing()判断是否在搬迁中,本质上就是看oldbuckets != nil。
这解释了为什么并发写如此致命:搬迁期间,同一个 map 的数据分散在两个数组里,由一个nevacuate计数器维护进度。此时如果另一个 goroutine 也在写,它可能:
- 把一个 key 写进新桶,而搬迁逻辑正准备从旧桶搬同一个 key;
- 修改了搬迁进度相关的状态;
- 读到一个"两边都没有"的中间态。
结果就是数据丢失、读到脏值,或者运行时检测到不一致直接throw。这不是小概率的时序巧合,而是结构上的必然。
也正因为搬迁是渐进的,一次并发写的影响可能在几千次操作之后才暴露——这就是这类 bug 最难复现的原因。
还有个坑:删掉的 key 不释放内存
m:=make(map[int]int,1000000)// 填入 100 万个元素fork:=rangem{delete(m,k)}// len(m) == 0,但 buckets 数组还在,内存没还给运行时delete只把槽位标记为"空",并递减count,桶数组本身不会收缩。Go 的 map 没有自动缩容机制。
如果 map 经历过一次流量高峰又回落,内存会一直占着。真需要释放,只能重新make一个新 map 把还在用的 key 搬过去,或者直接丢弃整个 map 让 GC 回收。
三种并发方案怎么选
方案一:sync.RWMutex+ 原生 map(通用首选)
typeSafeMapstruct{mu sync.RWMutex mmap[string]int}func(s*SafeMap)Get(kstring)(int,bool){s.mu.RLock()defers.mu.RUnlock()v,ok:=s.m[k]returnv,ok}func(s*SafeMap)Set(kstring,vint){s.mu.Lock()defers.mu.Unlock()s.m[k]=v}读多写少场景的首选。读锁之间不互斥,并发读性能很好;代码直白,行为可预测。绝大多数场景用它就够了。
方案二:sync.Map(别滥用)
sync.Map内部做了读写分离,用read(只读副本,无锁访问)+dirty(需要加锁)两层结构,把读操作的开销降到最低。
但它只在两种场景下真的更快:
- key 写入一次、之后只读(比如配置缓存、初始化后不再变的表);
- 多个 goroutine 各自读写互不相交的 key 集合。
如果你的场景是写多,或者key 频繁增删,sync.Map反而比RWMutex + map更慢——因为每次 miss 都要加锁穿透到 dirty,还要维护 miss 计数和 dirty 提升逻辑。
另外
sync.Map丧失了类型安全和len()的 O(1) 能力(要Range遍历计数)。不要因为"它是标准库提供的并发 map"就默认选它。
方案三:分片加锁(高并发写)
把 key 哈希到 N 个分片,每个分片一把锁,锁竞争降到 1/N:
typeShardedMapstruct{shards[]*SafeMap}func(s*ShardedMap)shardOf(kstring)*SafeMap{h:=fnv.New32a()h.Write([]byte(k))returns.shards[int(h.Sum32())%len(s.shards)]}适合写操作非常密集的场景(比如高频计数器)。代价是实现复杂度上升,且跨分片操作(比如求全量大小)需要锁住所有分片。
选型速查
| 场景 | 推荐方案 |
|---|---|
| 读多写少,通用 | sync.RWMutex+ map |
| 写后基本只读(配置、字典表) | sync.Map |
| 多 goroutine 读写不相交的 key | sync.Map |
| 写操作密集 | 分片加锁 |
| 只在启动时写、之后只读 | 不加锁,用闭包或 init 保证 |
排查速查表
| 现象 | 原因 | 处理 |
|---|---|---|
fatal error: concurrent map writes | 多个 goroutine 同时写 | 加锁或换sync.Map |
fatal error: concurrent map read and map write | 一边读一边写 | 同上 |
recover兜不住 | fatal error不是panic | 只能从源头修,无法兜底 |
| 偶尔读到错值但不崩 | 并发读写未命中检测 | go test -race定位 |
| 内存删完不降 | map 不缩容 | 重建 map 或丢弃 |
用了sync.Map反而变慢 | 写多场景不适合 | 换回RWMutex + map |
小结
关于 Go map 的并发,记住这四条:
- map 并发读写会
fatal error,且无法recover—— 这不是 panic,进程直接死; - 没崩 ≠ 安全—— 检测依赖时序,不崩的时候你可能正在读脏数据,用
-race判断; - 根因是渐进式扩容搬迁—— 搬迁期间新旧桶并存、由
nevacuate维护进度,并发写会直接破坏这个状态机; - 默认选
sync.RWMutex + map——sync.Map只在"写后只读"或"key 不相交"时才真的更快,别默认用它。
上一篇:[我以为 append 是复制,结果改了原数组:Go 切片的底层数组共享与扩容规则]
下一篇预告:goroutine 泄漏的四个真实场景,以及如何用 pprof 一次定位。