☰
Go map 为什么不能并发读写:fatal error 背后的扩容与渐进搬迁
2026/10/9 10:25:47 网站建设 项目流程
个人主页: > 我不会起名字322 < (欢迎各位大佬莅临😊)
其他栏目: > 技术栈学习笔记 <
其他栏目: > 力扣Hot100题目解析 <
其他栏目: > Go项目学习笔记 <
其他栏目: > redis <
其他栏目: > mysql <



文章目录

  • 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 的做法是渐进式搬迁:

  1. hashGrow只分配新桶数组,把旧桶挂到oldbuckets,设置nevacuate = 0,不搬任何数据;
  2. 之后每次对 map 的写操作(mapassign)或删除(mapdelete),顺手搬迁 1~2 个桶;
  3. 搬迁完成前,oldbuckets和buckets同时存在,读操作需要判断目标 key 在旧桶还是新桶;
  4. 全部搬完,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(需要加锁)两层结构,把读操作的开销降到最低。

但它只在两种场景下真的更快:

  1. key 写入一次、之后只读(比如配置缓存、初始化后不再变的表);
  2. 多个 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 读写不相交的 keysync.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 的并发,记住这四条:

  1. map 并发读写会fatal error,且无法recover—— 这不是 panic,进程直接死;
  2. 没崩 ≠ 安全—— 检测依赖时序,不崩的时候你可能正在读脏数据,用-race判断;
  3. 根因是渐进式扩容搬迁—— 搬迁期间新旧桶并存、由nevacuate维护进度,并发写会直接破坏这个状态机;
  4. 默认选sync.RWMutex + map——sync.Map只在"写后只读"或"key 不相交"时才真的更快,别默认用它。

上一篇:[我以为 append 是复制,结果改了原数组:Go 切片的底层数组共享与扩容规则]
下一篇预告:goroutine 泄漏的四个真实场景,以及如何用 pprof 一次定位。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询