Go map 的不安全,其实是一条数据可信度红线

Go map 不线程安全和并发写触发 fatal 不是两件事,是同一个设计取舍的两面:默认不替你付锁成本,但发现 map 状态无法安全背书时也不允许程序假装还能恢复——这是一条数据可信度红线。

封面

这段代码里,defer recover 写得很认真。

它像很多线上兜底代码一样:先把最危险的操作包起来,出了事就记录日志,然后让程序继续走。

defer func() {
    if r := recover(); r != nil {
        log.Println("recovered", r)
    }
}()

m["k"] = 1

这只是一个最小骨架。下面那组实验才负责对比 panicconcurrent map writes;两组实验已作为本文工作证据留存,发布版这里只取结果对比。

如果这里触发的是普通 panic,这段兜底确实有用。

但如果触发的是 Go 运行时那句熟悉的:

fatal error: concurrent map writes

它没有机会。

我跑了一个最小对比。普通 panic 可以被同 goroutine 里的 defer recover 接住,进程 exit code 是 0;并发写 map 触发 runtime fatal,stderr 第一行是 fatal error: concurrent map writes,进程 exit code 是 2。

普通 panic 与 map fatal 的实验结果对比

recover 没写错。

Go runtime 更像是在提醒你:这里出问题的不是一段业务控制流,而是一份可能不再可信的数据结构。

这个话题常被拆开讲。

先讲 map 为什么不线程安全。

再讲为什么它走 fatal,而不是 panic。

这样讲没错,但容易漏掉中间那根线:普通 map 默认不替你付锁成本;一旦 runtime 发现并发写让 map 状态无法安全背书,它也不允许程序假装还能恢复。

这两件事是一体的。

普通 map 是一份性能默认契约。runtime fatal 是一份可信度边界契约。两份契约相遇的地方,就是这篇文章要讲的红线。

一、第一份契约:普通 map 不替所有人付锁成本

先别急着骂 map 不安全。

Go 的普通 map 从来没有承诺过并发安全。它承诺的是另一件事:在最常见的单 goroutine 或外部同步场景下,给你一个足够快、足够轻的哈希表。

这不是文档缝隙。Go FAQ 里对这个选择说得很直接:经过讨论后,Go 团队认为 map 的常规使用不需要多个 goroutine 的并发安全;如果强制所有 map 操作都先拿 mutex,大多数不需要同步的程序会被拖慢。(出处:Go FAQ “Why are map operations not defined to be atomic?”,复核入口见文末。)

这句话背后有一个很朴素的工程判断。

并发安全不是免费的。

一个 map 操作如果要天然线程安全,至少要在某个地方付出代价。可能是锁。可能是 CAS。可能是更复杂的数据结构。可能是内存布局和缓存局部性上的妥协。

这些代价对并发读写场景是必要成本。但对大量普通 map 来说,它们只是无意义的税。

我跑了一组小 benchmark。环境是 Go 1.26.2、darwin/arm64、Apple M4 Pro。这是一组单 goroutine 微基准,只用来观察各同步机制的最低固定开销,不模拟真实并发竞争、CPU 核间争用或热点 key 行为。

测试分两组:

  • ReadMostly:约 99% 读,1% 写;
  • WriteHeavy:约 50% 读,50% 写。

普通 map 只是单 goroutine 基线,不代表并发安全方案。map + RWMutexsync.Map 才是显式同步路径。ReadMostlyWriteHeavy 只是读写比例标签,不代表多 goroutine workload。

场景 普通 map map + RWMutex sync.Map
ReadMostly 3.86 ns/op 4.85 ns/op(1.25x) 15.46 ns/op(4.00x)
WriteHeavy 5.76 ns/op 6.57 ns/op(1.14x) 33.36 ns/op(5.79x,1 alloc/op)

完整 benchmark 代码、go test 命令和原始输出已作为本文工作证据留存,发布版这里只保留方法边界与关键数据。

这个表不能推出「sync.Map 一定慢」。

这里必须把测量条件说清楚。

第一,普通 map 只是在单 goroutine 下作为基线。它快,是因为它没有同步语义。你不能拿这个数字去证明「生产里应该裸奔」。

第二,RWMutex 的成本在这个实验里看起来不大,但这是因为基准循环很小,锁竞争也很低。真实系统里,临界区里如果混进 JSON 编解码、RPC 调用、磁盘 IO,锁的成本就不再是几纳秒,而是排队和尾延迟。

第三,sync.Map 的数字最容易被误读。它在写多场景下明显更重,但它的设计目标也不是替代所有 map + mutex。它更像一个专门工具,主要为两类访问模式设计:某个 key 写一次、之后被多个 goroutine 大量读取,也就是 write-once-read-many;或者多个 goroutine 操作的 key 集基本不相交,也就是 disjoint key sets。

所以这组 benchmark 不是选型裁判,只是成本提醒。

如果一篇文章把这类数据写成「sync.Map 比 map 慢 5 倍,所以别用」,那是在滥用实验。本文只需要它支撑一个更小、更稳的结论:同步安全有真实成本,Go 不把这笔成本默认塞进普通 map,是有工程动机的。

sync.Map 有自己的适用场景:write-once-read-many 的只读放大型缓存,或者多个 goroutine 各自负责不同 key 集合的并发访问。它不是普通 map 的性能替身,也不是所有并发 map 的默认答案。

这个表只支撑一个更克制的判断:同步成本确实存在。

普通 map、RWMutex、sync.Map 的同步成本层级

哪怕在这个很小的本机实验里,RWMutex 也不是零成本,sync.Map 在写多场景下还出现了 1 alloc/op。真实系统里的结果会受 key 分布、读写比例、CPU 缓存、逃逸、GC 压力影响,但方向不会变:并发安全要花钱。

Go 的选择是把这笔钱交给使用者显式支付。

如果你知道 map 只在一个 goroutine 内使用,就用普通 map。

如果你知道它跨 goroutine 共享,就把同步边界写出来:加锁、用 channel 串行化、换 sync.Map,或者用不可变快照。

还有一类边界要单列:如果一份 map 在初始化阶段构造完成,之后所有 goroutine 只读不写,并且发布过程有明确的 happens-before,比如初始化结束后再启动消费 goroutine,或通过锁、atomic、channel 发布完整指针,那么普通 map 在并发只读路径上也是安全的。

到这里,第一份契约就清楚了。

普通 map 不保证并发安全,不是 runtime 不会加锁,而是 Go 不想让每一次普通使用都为并发场景买单。

二、第二份契约:runtime 无法安全背书时,不能假装可恢复

那问题来了。

如果 Go 不替普通 map 加锁,为什么发现并发写时不抛一个普通 panic,让用户自己 recover

答案藏在 panic 和 fatal 的边界里。

panic——走 defer/recover 语义的可恢复异常控制流。函数开始展开栈,defer 逐个执行,某个 defer 里可以调用 recover,于是控制权回到程序手里。

fatal / throw——runtime 判断当前状态已经不适合继续。它不走普通的 defer/recover 语义,而是打印 fatal 信息并终止进程。

普通 panic 的含义更接近「这段业务逻辑出错了」。比如 slice 越界、主动 panic(err)、解析配置失败后不想继续初始化。你可以决定这个错误能不能被兜住。

runtime fatal 的含义更重。

它不是在问「这次调用能不能失败」。它是在说「我不能再相信当前运行状态能安全继续」。

Go map 的并发写检测,大致靠 hashWriting 这类标志位完成。写入路径会在关键位置检查是否已有写入正在进行;如果发现冲突,就调用 runtime 的 fatal 路径。

源码细节可以看 runtime 里的 map 写入和 panic/throw 路径。正文不需要逐行摊开。关键只有一条链:map 写入发现并发写风险,进入 runtime throw/fatal,而不是普通 gopanic

我跑的 E1 实验就是为这个边界服务的。

普通模式:

recovered ordinary panic: ordinary panic
exit code: 0

并发写 map 模式:

fatal error: concurrent map writes
exit code: 2

这两行输出比一大段源码更直观。

实验里还有一个细节:每个写 map 的 goroutine 内部也放了 defer recover。如果这是普通 panic,它应该至少在当前 goroutine 有机会执行恢复逻辑。但 fatal 发生后,runtime 直接打印错误并终止进程,那个 recover 没有成为修复入口。

这也是很多人第一次遇到这个错误时不适应的地方。

在业务代码里,我们习惯把 panic 看成一种「更激烈的 error」。它虽然粗暴,但仍然在语言层的异常控制模型里。你可以通过 defer 做清理,可以通过 recover 做隔离,也可以在框架边界把它转成 500 响应。

runtime fatal 不在这个模型里。

它更接近运行时的自我保护:某个内部不变量可能已经被破坏,或者继续执行会让风险继续扩大。此时最重要的动作不是把控制权还给业务,而是停止替这份状态做安全背书。

所以,讨论 concurrent map writes 时,单纯问「为什么不 panic」会问偏。

换一种说法,普通 panic 的默认语义是把「是否接住」留给程序;map 并发写触发的 fatal,则是 runtime 提前替程序做掉这个决定。

同样是「程序出错」,普通 panic 给你恢复控制流的机会;map 并发写触发的 fatal 不给。

gopanic 与 throw/fatal 的运行路径对照

这不是 Go 冷酷。

是 recover 的能力到这里就停了。

recover 可以把控制流拿回来,但它不能证明 map 的内部状态仍然一致。一个哈希表在扩容、搬迁 bucket、写入 key/value 的过程中被多个 goroutine 同时改动,runtime 很难给你一个便宜又可靠的「回滚到一致状态」按钮。

如果只是让用户拿回控制流,然后继续读写这张 map,表面上程序活着,实际可能更糟。

因为接下来的每个判断,都可能建立在一份无法证明仍然一致的数据上。

第二份契约的落点就在这里。

runtime 不承诺替你证明这张 map 仍然一致。它只承诺:当它发现这条可信度边界被踩穿时,不让程序继续装作没事。

三、两份契约相遇的地方,就是数据可信度红线

把两份契约放在一起,Go map 的设计就不矛盾了。

第一份契约说:普通 map 默认不加锁。你要并发安全,就显式声明边界。

第二份契约说:一旦 runtime 发现 map 正处在无法安全背书的并发写时序里,它不会把这件事包装成可恢复异常。

前者保护的是大多数场景的成本。

后者保护的是数据结构出事后的诚实。

普通 map 的性能契约与 runtime 的可信度契约

我把这条线叫作「数据可信度红线」。

它不代表 Go 能检测所有 map 数据竞争。Go 做不到。

读写并发、复杂时序、没有踩中 hashWriting 检查的竞争,仍然可能需要 race detector 才能暴露。fatal error: concurrent map writes 不是一套完整的数据竞争检测系统。

runtime 实际上还有 concurrent map read and map writeconcurrent map iteration and map write 等几条 map 专项检测路径,但它们都是危险点抓拍,不是通用 race detector。

它的覆盖面是有限的:runtime 在它能看见的危险点上做拦截,看不见的并发模式仍要靠 race detector 提前发现。

这里有一个容易混淆的点。

很多人把「进程退出」当作最坏结果。

在服务端工程里,进程退出当然很难看。监控会报警,流量会迁移,用户可能看到错误。生产环境里没人喜欢 fatal。

但对一份已经无法证明仍然可信的状态来说,退出未必是最坏结果。

更危险的是另一种情况:进程还活着,继续根据一份无法安全背书的 map 做鉴权、路由、限流、配置读取,然后把错误扩散出去。

想象一个服务把租户配置放在普通 map 里,多个 goroutine 同时刷新和读取。某次并发写发生时,如果程序只是吞掉错误继续跑,接下来每一次鉴权、路由、限流都可能建立在一份无法证明一致的状态上。

这个场景里,最麻烦的不是「请求失败」。

请求失败至少是显性的。监控能看到,调用方能重试,网关能熔断,调度系统能拉起新实例。

更麻烦的是「请求成功,但依据可能已经不一致」。

比如租户限流配置停在旧值,开关状态只更新了一半,路由规则在两个版本之间来回跳。它们不会都变成崩溃。很多时候,它们会变成一批看起来随机、复现困难、日志也不完整的业务异常。

这类异常最消耗工程团队,因为它们不是单点失败,而是信任基础开始松动。

所以我更愿意把 map fatal 看成一道边界,而不是一种错误处理风格。它的目的不是让程序显得强壮,而是拒绝把无法证明可信的状态伪装成可服务状态。

这不是某个公司的事故复盘,也不是在断言每次 map fatal 都会造成鉴权事故;它只是说明一个常见风险模型:状态污染比显性失败更难排查。

你可以把 recover 理解成急刹车。

业务代码 panic 了,急刹车有用。车还在路上,车架没有断,司机可以决定靠边、重试、降级。

recover 急刹车与 fatal 封桥的类比

runtime fatal 更像封桥。

桥梁结构可能已经出问题,管理者不会继续为通行安全背书。你可以抱怨封桥影响通勤,但不能把「继续开」当成更优雅的方案。

这个类比不能替代论据,只能帮助理解边界。

支撑这个判断的,还是前面两组论据:

  • recover 对普通 panic 有效,对 map fatal 无效;
  • 同步成本可测,普通 map 默认不带锁有现实动机。

所以别把本文读成一句「Go map 不安全,大家小心」。

我更想说的是:Go 把并发安全从普通 map 的默认契约里拿掉,交给使用者显式建模;但一旦 runtime 发现这个边界被踩穿,它不会把无法证明一致的状态交还给业务代码继续处理。

这比一句「map 不是线程安全的」更接近 Go 的真实取舍。

四、反过来看工程选择:你要的不是 recover,而是显式同步边界

理解这条红线之后,工程上的建议反而变简单了。

不要围绕 recover 设计 map 并发安全。

recover 是最后的兜底,不是同步机制。

如果一份 map 会被多个 goroutine 读写,要先写清楚所有权和同步边界。

第一种情况:map 只有一个 goroutine 拥有。

这时普通 map 是最自然的选择。你可以通过 channel 把写请求串到同一个 goroutine,让所有修改在一个事件循环里完成。读写都不跨边界,就没有额外锁成本。

它的核心是所有权清楚——不是 channel 比锁高级。

谁能改这张 map?只有那个 goroutine。

谁想改?发消息。

谁想读?要么也发消息,要么读一份快照。

一旦所有权清楚,普通 map 的不线程安全就不再是缺陷。它只是一个没有内置同步的容器,恰好放在一个已经同步好的边界内部。

第二种情况:跨 goroutine 只读,写在发布期完成。

如果 map 在初始化阶段构造完整,发布之后不再原地修改,普通 map 也可以服务并发只读。关键不在「有没有跨 goroutine」,而在发布动作是否建立了清楚的 happens-before。常见做法是初始化完成后再启动消费 goroutine,或通过锁、atomic、channel 发布完整指针。

第三种情况:读多写少,写入路径明确。

map + sync.RWMutex 通常是最直观的方案。它的好处是语义清楚:谁读,谁拿读锁;谁写,谁拿写锁。缺点也清楚:你要维护锁的范围,不能在持锁期间做太重的操作,也要避免锁顺序问题。这里的主轴仍然是把同步边界写清楚。

第四种情况:写一次读多次,或多个 goroutine 操作不相交 key 集。

sync.Map 可以进入候选。它不是「更高级的 map」,而是为特定访问模式做过权衡的数据结构。用它之前,最好先问:你的 workload 是否真的符合 write-once-read-many 或 disjoint key sets 这两类优势区间?

第五种情况:状态周期性刷新,读路径希望尽量简单。

可以考虑不可变快照。

写路径构造一份新 map,填充完整后一次性替换指针;读路径只读当前快照,不在原地修改。这个方案的代价是内存和更新延迟,但它能让读路径非常干净,也能减少锁对热点请求的影响。

它适合配置、路由表、规则表这类「偶尔刷新、大量读取」的结构。这本质上还是把同步边界写在发布动作里:一旦发布完成,旧快照不再被原地修改,读路径就回到了「普通 map + 安全发布」的合法边界,而不是真的没有同步。

第六种情况:你不确定自己有没有数据竞争。

先跑 race detector。

go test -race 的成本很高,不适合一直挂在线上热路径,但它在测试期非常有价值。race detector 不是 runtime fatal 的替代品。一个是测试期的广谱扫描,一个是生产期的低成本兜底。

这两者的配合关系很重要。

race detector 的目标是尽早发现你没想清楚的共享状态。它贵,所以适合测试、CI、压测阶段。

runtime 的 map fatal 很便宜,但覆盖面有限。它不是完整的竞争检测器,只是在特定危险点发现了并发写。你不能因为线上没有 fatal,就推断代码没有数据竞争。

换句话说,race detector 负责「提前发现」,runtime fatal 负责「看见红线后停止」。

race detector 与 runtime fatal 的职责分工

可以先用这张表做粗筛。

场景 更合适的方向 理由
单 goroutine 拥有 map 普通 map 不需要同步税
跨 goroutine 只读,写在发布期完成 普通 map + 安全发布 发布后不再原地修改,读路径无锁
多 goroutine 共享,读写边界清楚 map + RWMutex 语义直接,成本可控
write-once-read-many 或 disjoint key sets sync.Map 利用专门设计,不手写复杂锁
状态周期性刷新,读路径要无锁 不可变快照 + atomic 替换 读路径简单,写路径整体替换
不确定有没有竞争 race detector 测试期提前暴露问题

Go map 并发访问的工程选择边界

这个表不追求覆盖所有情况。

它只想强调一件事:并发安全应该出现在设计里,而不是出现在 crash 后的 recover 里。

如果你把普通 map 放进多个 goroutine 之间共享,又没有锁、没有 channel、没有不可变快照,那其实是把同步成本暂时赊账了。

这笔账可能不会马上来。

测试数据少、并发度低、调度时机刚好错开时,程序看起来会工作很久。然后某次发布、某次流量尖峰、某次 CPU 拓扑变化,那个隐藏的共享状态突然被同时写到了。

这也是 concurrent map writes 最容易制造错觉的地方:它不像编译错误那样稳定复现。它更像运行时对某个危险时序的抓拍。抓到了,就 fatal;没抓到,不代表没问题。

所以不要把「我本地没复现」当成安全证明。

安全感要从结构里来:所有权单一,锁边界清楚,快照不可变,race detector 通过。

runtime fatal 只是账单最刺眼的一种寄达方式。

Go runtime 的克制:不替你付锁成本,也不掩盖无法背书的状态

五、克制不等于温柔

Go 的很多设计都不迷恋「自动帮你」。

普通路径上,Go 不替你支付你没声明过的成本。

危险路径上,Go 不替你掩盖它已经无法安全背书的状态。

这是一种克制。

克制不等于温柔。它有时候会很硬,硬到直接把进程打掉。

但它至少诚实。

公开资料与证据说明

本文的两组实验和 benchmark 原始输出已作为工作证据留存,发布版不展开本地文件路径。公开可查的资料主要是这两处:

  • Go FAQ 官方说明:https://go.dev/doc/faq#atomic_maps,问题标题是 “Why are map operations not defined to be atomic?”。
  • sync.Map 官方适用场景:https://pkg.go.dev/sync#Map

结尾:退出不是最坏结果

回到开头那段代码。

defer recover 没错。

只是它接住的对象,和 concurrent map writes 所代表的问题,不在同一层。

普通 panic 是控制流问题。你可以恢复,可以降级,可以重试。

runtime fatal 之后的恢复,应该交给进程级治理:supervisor 重新拉起,请求层保持幂等,状态从权威源重建。它不该交给业务代码里的 recover

map 并发写触发的 fatal,是 runtime 对数据可信度的拒绝背书。它不想让一份可能已经无法证明一致的结构继续参与计算。

Go 不会替你给每扇门上锁。

但桥已经裂开时,它会先把桥封掉。

退出不是最坏结果,无法背书的数据继续流动才是

如果一份数据已经无法被证明可信,最危险的不是程序退出,而是程序假装自己还能继续——尤其是当下游每一次决策都建立在这份数据上的时候。


原文发布于 止语Lab


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →