bigywater.tech
Go · 23

并发读写 map:运行时是怎么检测并 fatal 的

map 不是并发安全的,但 Go 的运行时会尽力把这种错误变成一个立即的崩溃,而不是悄悄写坏数据。这篇用真实程序触发三种 fatal,验证 recover 接不住,量出检测的命中率,再读源码里检测用的那个标志,最后看加锁与 -race 的结果。

退出码 2
并发读写 map 触发的是 fatal error,不是 panic,defer 和 recover 都不会执行
2 / 30
两个 goroutine 各只写 3 次,30 次运行里被检测到的次数;各写 10 万次则是 30 / 30
writing
Map 里的标志位:写入期间被翻转,别的 goroutine 看到它不对就 fatal

三种 fatal

实测 · 本机执行输出
  两个 goroutine 同时写(模式 ww):退出码 2;fatal error: concurrent map writes;输出里有 "recover 收到"?False
  一个写、一个读(模式 rw):退出码 2;fatal error: concurrent map read and map write;输出里有 "recover 收到"?False
  一个遍历、一个写(模式 iw):退出码 2;fatal error: concurrent map iteration and map write;输出里有 "recover 收到"?False
  (每个程序都用 defer + recover() 包住了 main:fatal error 不是 panic,defer 没有机会执行)

写与写、读与写、遍历与写都被检测。它们是 fatal error,等价于运行时认定程序已经不可恢复,所以 recover 接不住,进程以退出码 2 结束并打印所有 goroutine 的栈。生产环境里没法靠 recover 兜底这类错误。

不保证发现

实测 · 本机执行输出
  2 个 goroutine 各写 10 万次:30 次里 fatal 30 次、正常结束 0 次;fatal 的信息分布:{'fatal error: concurrent map writes': 30}
  2 个 goroutine 各只写 3 次:30 次里 fatal 2 次、正常结束 28 次;fatal 的信息分布:{'fatal error: concurrent map writes': 2}

检测靠的是两个 goroutine 恰好同时处在 map 的写入窗口里。写 10 万次时窗口大量重叠,30 次全中;每个只写 3 次时,窗口几乎不重叠,28 次安静地结束。没触发 fatal 的程序仍然有数据竞争,-race 才是可靠的检查手段。

检测的原理

实测 · 本机执行输出
  internal/runtime/maps/map.go:432:		fatal("concurrent map read and map write")
  internal/runtime/maps/map.go:451:		fatal("concurrent map read and map write")
  internal/runtime/maps/map.go:507:		fatal("concurrent map writes")
  internal/runtime/maps/map.go:533:			fatal("concurrent map writes")
  internal/runtime/maps/map.go:548:			fatal("concurrent map writes")
  internal/runtime/maps/map.go:677:		fatal("concurrent map writes")
  internal/runtime/maps/map.go:703:		fatal("concurrent map writes")
  internal/runtime/maps/map.go:762:		fatal("concurrent map writes")
  internal/runtime/maps/map.go:789:		fatal("concurrent map writes")
  internal/runtime/maps/map.go:808:		fatal("concurrent map clone and map write")
  internal/runtime/maps/table.go:882:		fatal("concurrent map iteration and map write")
  252:	// writing is a flag that is toggled (XOR 1) while the map is being
  253:	// written. Normally it is set to 1 when writing, but if there are
  256:	writing uint8
  431:	if m.writing != 0 {
  450:	if m.writing != 0 {

源码里 Map.writing 的注释写得很明确:写入期间它被“XOR 1”翻转;注释里还说明了为什么是翻转而不是置位:多个写者同时进入时,翻转能提高两边都检测到的概率。读路径与迭代器也检查这个标志。这是一个廉价的运行时检查,而不是锁:它不阻止竞争,只是在发现时崩溃。

加同步与 -race

实测 · 本机执行输出
  mutex:10 次里正常结束 10 次;fatal 0 次
  syncmap:10 次里正常结束 10 次;fatal 0 次
  -race 编译的 ww 模式:退出码 2;输出里的关键行:WARNING: DATA RACE / Write at 0x… by goroutine 7: / Previous write at 0x… by goroutine 8: / fatal error: concurrent map writes
  -race 编译的 mutex 模式:退出码 0;输出:正常结束,len = 200000 / recover 收到: <nil>

用 sync.Mutex 包住 map,或者换成 sync.Map,都不再出错。-race 编译无保护的版本时,竞态检测器先报告 DATA RACE(并指出两个 goroutine 的写位置),运行时的 fatal 随后依然触发。读多写少的场景可以用 sync.RWMutex(见后面的 RWMutex 一篇),sync.Map 适用的场景见 sync.Map 一篇。

分步演示

并发读写 map
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。每个模式是同一个程序的不同命令行参数,作为独立进程运行(退出码与输出来自真实进程);“30 次”的统计每次运行会有变化;读过的源码:internal/runtime/maps/map.go(writing 标志与各处 fatal 调用)与 table.go(迭代器里的检查)
    • 没有验证:Swiss map 以外的类型(如自己用切片实现的查找表)的行为、sync.Map 与 RWMutex 的性能比较(见各自的文章)、检测在 GOARCH 其它平台上的命中率、cgo 回调里的并发写
    京ICP备2026067502号-1京公网安备11010502063487号