fatal error: concurrent map read and map write:Go map 并发崩溃现场与 sync.Map 边界
最小复现:所谓只读缓存里藏着一个写路径
线上最容易出现这种崩溃的形态是:一个 map 被多个请求 goroutine 并发读,同时某个后台任务会定时刷新、懒加载回填,或删除过期 key。业务逻辑里它被当成“只读缓存”,但在 runtime 视角里它一直有并发写。
下面的最小复现只保留了一个 writer 和 8 个 reader:
package main
import "sync"
func main() {
cache := make(map[string]int)
var wg sync.WaitGroup
wg.Add(9)
// writer:定时刷新/回填
go func() {
defer wg.Done()
for i := 0; i < 1_000_000; i++ {
cache["counter"] = i
}
}()
// readers:业务读取路径
for i := 0; i < 8; i++ {
go func() {
defer wg.Done()
for j := 0; j < 1_000_000; j++ {
_ = cache["counter"]
}
}()
}
wg.Wait()
}
go run main.go 大概率直接打出:
fatal error: concurrent map read and map write
exit status 2
如果这次侥幸跑完了,也不代表没有竞争,只是 runtime 的检测窗口没撞上,后面会解释。
hashWriting:runtime 的 fail-fast 机制
内建 map 不是并发安全容器。它在运行时用 hmap.flags 里的一个标志位做“尽力检测”:
hashWriting的值是4- 写路径
mapassign、mapdelete在修改前会检查h.flags & hashWriting - 读路径
mapaccess在入口也会检查同一个标志 - 一旦发现标志已经被置位,直接走
runtime.throw(),而不是普通 panic
所以错误在 Go 1.x 里会以 fatal error 形式出现,进程以 exit code 2 退出。recover() 拦不住这类错误,它不是可恢复的 panic,而是 runtime 主动终止程序。
这是刻意的 Fail-Fast 设计。map 的内部结构由 bucket、overflow 链表、扩容状态组成,并发写可能把这些状态改坏。如果继续运行,可能发生:
- bucket 链表损坏,甚至形成循环链表,导致遍历该 map 的 goroutine 长时间占用一个核心
- 扩容中途状态错乱,读到半迁移的数据
- 静默返回错误数据,但程序毫无感知
runtime 不能提供事务式回滚,也不能保证给出一致视图。它只能保证:检测到并发写后不让你带病运行。
“并发读安全”需要 happens-before 保证
很多人理解成“只有写才需要加锁,读天然安全”。这对 Go map 不成立。
唯一成立的形态是:map 在发布之后没有任何 goroutine 再写它,并且发布动作建立了 happens-before。例如初始化完成后通过 go 语句启动消费方,或通过 channel、atomic.Pointer、锁发布整个 map 指针。这种情况下多个 goroutine 同时读才安全。
一旦存在以下任意一条路径,读安全就不再成立:
- 定时刷新
- 懒加载回填
- 过期删除
- 记录访问次数后写回
这些逻辑常常藏在很深的调用链里。事故排查时通常只看得到读代码,看不到另一个文件里的写函数。
hashWriting 检测不是 race detector
hashWriting 检查只能抓“写标志恰好被置位时,另一个入口也来了”的瞬间。它没有 happens-before 信息,也不做完整的数据竞争检测。
所以会出现两种看似矛盾的现象:
- 程序一直没崩,但 map 数据已经被并发读写污染
- 本地 go run 没复现,线上高并发一跑就 fatal
正因为这样,涉及并发 map 的测试必须上 race detector:
go test -race ./...
-race 会插桩并记录内存访问的 happens-before 关系,能在测试覆盖到的执行路径上报告真正的 data race。runtime 的 hashWriting 检查只能当最后一道兜底,不能当测试工具。
sync.Map 只解决两类特定冲突
sync.Map 内部维护 read 和 dirty 两张表,read 表通过原子操作加载。读命中 read 时不需要拿锁;新 key 先进入 dirty,当 miss 计数累积到不小于 dirty 长度时,dirty 整张提升为 read,这一步开销是 O(n)。
官方文档里它的优势场景只有两类:
- 某个 key 只写一次,但被读很多次,键集稳定,类似只增长的缓存
- 多个 goroutine 分别读写不相交的 key 集合
场景偏离后,sync.Map 会明显退化:
- 写密集且 key 频繁增删时,dirty 会被反复重建、提升,整表复制成本高
- 删除是惰性的,同一个 key 的指针可能同时留在 read 和 dirty 两张表里;清理要等后续提升,内存可能接近两倍
- 慢路径最终仍落在一把全局
mu上,miss 越高,锁竞争越明显
另外,sync.Map 没有 Len(),长度只能自己 Range 数。值类型是 any,Load 出来需要断言,不是泛型容器。
默认选择:map + 锁
工程上默认选择不应该是 sync.Map,而是普通 map 加一把锁。
读多写少的场景,RWMutex 足够,而且代码简单、类型安全、行为可预测:
type Cache struct {
mu sync.RWMutex
m map[string]int
}
func NewCache() *Cache {
return &Cache{m: make(map[string]int)}
}
func (c *Cache) Get(key string) (int, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.m[key]
return v, ok
}
func (c *Cache) Put(key string, v int) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[key] = v
}
func (c *Cache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.m, key)
}
如果还需要多个字段联动的一致性,或者要求强类型、能直接取 len/snapshot,普通 map 加锁也远比 sync.Map 直观。
sync.Map 的对应写法:
type Cache struct {
m sync.Map
}
func (c *Cache) Get(key string) (int, bool) {
v, ok := c.m.Load(key)
if !ok {
return 0, false
}
n, ok := v.(int)
return n, ok
}
func (c *Cache) Put(key string, v int) {
c.m.Store(key, v)
}
func (c *Cache) Delete(key string) {
c.m.Delete(key)
}
如果压测确认写密集且 key 分布均匀,单锁是瓶颈,再考虑分片锁。分片数取 2 的幂,从 32 或 64 开始压测,不要凭空拍一个数。
性能验证
具体读写比例下的收益只能靠本地基准验证,不要照抄网上数字。方向大致是:
- 读占比很高(例如 90% 以上)且键集稳定,sync.Map 有机会占优
- 读写均衡、写密集或 key 频繁增删,map + 锁通常更稳
验证时注意:race 检查交给 go test -race,benchmark 不要带 -race,插桩会改变时间与调度特征:
go test -bench=. -benchmem -run=^$
sync.Map 不是“更快的 map”,它只是一把针对特定竞争形态重新设计过的锁。默认情况下,map + RWMutex 才是应该先写出来的版本。