编程 fatal error: concurrent map read and map write:Go map 并发崩溃现场与 sync.Map 边界

2026-09-07 00:09:16

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
  • 写路径 mapassignmapdelete 在修改前会检查 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 信息,也不做完整的数据竞争检测。

所以会出现两种看似矛盾的现象:

  1. 程序一直没崩,但 map 数据已经被并发读写污染
  2. 本地 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)。

官方文档里它的优势场景只有两类:

  1. 某个 key 只写一次,但被读很多次,键集稳定,类似只增长的缓存
  2. 多个 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 才是应该先写出来的版本。

复制全文 生成海报 Go开发 Go 并发 sync.Map

推荐文章

程序员茄子在线接单