一次 map 并发写崩溃,让我重新理解了 Go runtime

线上服务在压测时挂了一次。日志里最显眼的是这一行:
1 | fatal error: concurrent map writes |
它后面其实跟了一段堆栈,但那段堆栈只指向“发现冲突的那个 goroutine”——另一个对手是谁,它并不知道。也没有任何可以 recover 的 panic 信息,程序直接以 exit(2) 结束。更让人困惑的是,这段代码在本地跑了一周都没事,QPS 一上来几秒钟就崩了。
这篇文章不打算罗列“Go map 怎么加锁”的几种方式,而是回答一个更根本的问题:为什么 Go 选择让程序直接死掉,而不是像其他语言那样返回一个错误,或者至少让你 recover 一下?
答案藏在 runtime 的设计哲学里。
一、崩溃现场:一个典型的“看起来没问题”
出问题的结构大致是这样:
1 | var cache = map[string]string{} |
包级 map,HTTP handler 里同时调 Get 和 Set。本地单线程测试没问题,压测时多个 goroutine 同时进来,就炸了。
触发的是两种 fatal error 之一:
concurrent map read and map write:一个 goroutine 读,另一个在写;concurrent map writes:两个 goroutine 同时写。
关键在于:它不是 panic,是 throw。
二、runtime 是怎么发现的:一个标志位
这里要先说清版本。Go 1.24 起,内置 map 换成了基于 Swiss table 的实现,代码在 internal/runtime/maps;在此之前是 runtime/map.go 里的 hmap + bucket。两代实现的检测思路一样,但细节不同,网上的分析经常混着讲。
现行实现(Go 1.24+)
Map 结构体里有一个 writing uint8 字段。写操作不是“上锁”,而是翻转这个标志位,并在进入和退出时各检查一次:
1 | // 写操作(赋值、删除) |
三个细节值得注意:
- 用异或而不是置 1/清 0。 源码注释说得很直白:并发写者不止一个时,翻转能提高双方都发现竞争的概率。如果只是置位,后到的写者看到 1 就死了;异或让状态在 0/1 之间来回翻,两个写者都更容易撞见对方留下的痕迹。
- 进入和退出各查一次。 退出时的
if m.writing == 0很容易被忽略:如果另一个写者在中途把标志翻回去了,这里也能抓到。 - 遍历有单独的报错。
mapiterinit检查的是同一个字段,但报的是concurrent map iteration and map write。
历史实现(Go 1.23 及更早)
旧实现是 hmap 结构体里的 flags 字段,其中一位叫 hashWriting,值是 4:
- 写操作开始时
h.flags |= hashWriting,结束时h.flags &^= hashWriting; - 读操作检查
h.flags & hashWriting != 0,发现有人在写就fatal。
思路完全一样,只是位运算和异或的区别。所以后面讨论的“为什么是 throw”,对两代实现都成立。

图 1:runtime 用的是“一个标志位 + 检查”,不是锁。写者进入时翻转标志,另一个写者或读者撞见非零值就触发 fatal;两边的操作并没有被串行化。
注意:这只是一个检测,不是同步。 两个 goroutine 可能刚好错开,也可能刚好撞上。所以表现为“偶发”——本地不崩,压测崩;低流量不崩,高流量崩。
还有一个容易踩的坑:对非空 map,delete 即使 key 不存在,也会走写路径、设置写标志。遍历时如果另一个 goroutine 在删任何 key,遍历都可能触发 fatal。
三、为什么是 throw,而不是 panic
这是整个设计中最关键的问题。
普通的 panic 会创建一个 _panic 结构体,挂到当前 goroutine 上,然后逆向遍历调用栈,执行 defer。如果某个 defer 里调了 recover(),程序就能继续跑。
throw 完全绕过这套机制。它把 fatal error 和当前 goroutine 的堆栈打印到标准错误,然后 exit(2)。没有 defer,没有 recover,没有“恢复”这个选项。
它选择这么做的理由很直接:当 map 的并发冲突被检测到时,内部状态可能已经损坏了。
想象一下:一个 goroutine 正在扩容、把旧表里的槽位搬到新表,另一个 goroutine 同时在读旧表的指针。runtime 发现的时候,控制字和槽位可能已经被改得面目全非——而检测本身只是一个标志位,它只知道“有人撞车了”,并不知道撞坏了多少东西。如果允许你 recover,程序继续跑,后续可能出现更诡异的行为——读到错误的数据、写入丢失、甚至内存越界。
直接死掉,是比“带着损坏的状态继续运行”更负责任的选择。
对比一下 Java 的 ConcurrentModificationException:它允许你捕获,然后继续用那个已经被并发修改过的集合。很多时候你的程序不会立刻崩,但数据对不对,就不好说了。
Go 选择了另一条路:在检测到确定性错误的那一刻,终止进程。 用一次响亮的崩溃,换掉后续所有不可预测的 bug。

图 2:panic 与 throw 的分岔——panic 会走 defer 链,路上还有 recover 这个出口;throw 直接打印堆栈并 exit(2),没有回头路。
四、sync.Map 不是万能解药
知道原因之后,修复方式看起来很简单:加锁,或者换成 sync.Map。
但 sync.Map 的适用场景很窄,而且它的内部实现这些年也变过。
Go 1.24 起,sync.Map 换成了并发哈希 trie(internal/sync.HashTrieMap):读操作沿树做原子加载,通常无锁;写操作只在命中的中间节点上加锁并替换节点。官方发布说明强调的改进是——修改不相交的 key 集合时,大 map 上的争用明显变少,而且不再需要预热(老实现要先攒出 dirty map,才能进入低争用状态)。
Go 1.23 及更早,它是 read + dirty 两层结构:读大多走 read 这层无锁快照,写则先在 dirty 里累积,攒够次数再提升为新的 read。
两代实现的共同结论没变:它的优势都在读多写少、key 集合相对稳定的场景。写非常频繁时,sync.Map 的写路径(加锁、替换节点、分配)未必比一把 sync.RWMutex 便宜。而且无论哪一代,Range 都不保证一致性快照——遍历期间看到的可能是部分更新后的状态。
所以实际选型通常是:
| 场景 | 推荐 |
|---|---|
| 读多写少,key 稳定 | sync.Map |
| 读写均衡,或需要强一致遍历 | sync.RWMutex 保护的普通 map |
| 高并发,锁竞争严重 | 分片锁(按 key hash 分到 N 个 map,各一把锁) |
分片锁的思路和 Redis 的渐进式 rehash 有相似之处:把一个大问题拆成 N 个小问题,让冲突概率降下来。
五、重新理解 runtime 的“不宽容”
这次崩溃之后,我对 Go runtime 的看法变了一点。
以前觉得 fatal error 太粗暴,为什么不能给个错误码,或者至少让我记录一下现场。后来想明白了:map 的并发冲突不是“可以优雅处理”的错误,它是一个已经发生的数据损坏事件。
runtime 在 writing(老版本是 hashWriting)标志上做的检测,本质是一种尽力而为的哨兵。它不保证一定能发现(两个写操作可能刚好错开标志位检查),但一旦发现,说明冲突已经真实发生了。这时候程序内部的状态是否还能安全使用,没有人能保证。
所以它选择 throw。用进程的死亡,阻止损坏状态的扩散。
这其实和 Redis 渐进式 rehash 的设计哲学形成了有趣的对照:Redis 用复杂度换可用性,因为它是单线程的,不能让主线程卡住;Go 用进程死亡换正确性,因为 map 的状态一旦损坏,继续运行比崩溃更危险。
两种选择,面对的是不同层面的“正确性”。
如果你也在线上遇到过这个崩溃,第一件事不是急着加锁,而是用 go test -race 或 go run -race 在测试环境复现一次。-race 能告诉你哪个 goroutine 在写、哪个在读、以及它们各自的调用栈。这比线上盲猜高效得多。
至于那个包级 map——修法很简单,难的是意识到:Go 的 map 从来没有承诺过并发安全,而 runtime 用最不宽容的方式,让这个承诺变得无法忽视。
引用与致谢
封面图
- 图片:美国国家步道系统,安塞尔·亚当斯的荒野,来源 bing。
动图(图 1 ~ 图 2)
- 生成工具:Animated Sketch Diagram —— 一个把技术概念画成「黑墨手绘涂鸦 + 米色纸面」风格动画图的 Claude Code 插件。
- 项目地址:https://github.com/OLDyade/animated-sketch-diagram
- 作者:OLDyade
- 许可:见项目仓库中的 LICENSE 文件。
- 说明:两张动图均由该工具把正文对应段落转成 SVG + CSS 动画后导出为循环 GIF,图中的结构为示意,不是 runtime 内存布局的精确复刻。
事实核对
- 旧实现(Go 1.23 及更早):
src/runtime/map.go中的hashWriting = 4与h.flags检查,见 go1.23.0 源码。 - 现行实现(Go 1.24+):
src/internal/runtime/maps/map.go中的writing字段与异或翻转,本文按作者本机 Go 1.27.1 的源码核对。 sync.Map的实现变更:Go 1.24 Release Notes(sync.Map与内置 map 都在这个版本换了实现)。- 文中崩溃输出(
fatal error+ 堆栈 + 程序exit(2))来自作者本机 Go 1.27.1 的实际复现;注意用go run运行时,外层工具自身会返回 1,日志里的exit status 2才是程序本身的退出码。
感谢阅读。