Go程序在压测或者流量高峰的时候突然整个进程退出,日志末尾甩出一大段goroutine栈,中间夹着一行fatal error: concurrent map read and map write。和平时见的panic不一样,它拦不住,defer里写好recover也拦不住,进程照样退。这不是recover放错了位置,是runtime走的是throw而不是panic,fatal级别的错误根本不进panic那条路。
为什么recover抓不住它
map的内部结构里有个标志位叫hashWriting,写操作进去之前置位、出来时清掉。runtime发现有并发的写,或者读操作撞上了正在迁移的扩容过程,就直接throw,把栈打出来然后exit(2)。设计上是有意为之:map并发读写属于内存损坏级别的bug,与其让程序带着坏数据继续跑,不如当场死在案发地。recover只管panic,这条路径不经过它。
顺带说一句,别指望调小GOMAXPROCS能躲过去,那只是把命中概率降了,问题原封不动还在。
先复现,再谈怎么修
最小的一段复现代码长这样:
func main() {
m := make(map[string]int)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
m[fmt.Sprintf("k%d", n)] = n
}(i)
}
wg.Wait()
}
跑起来几乎必崩。这里还有个版本上的差别值得记一下:Go 1.21、1.22、1.23、1.24、1.25、1.26,从1.22开始,go.mod里go指令写1.22或更高时循环变量按每轮独立副本处理,上面那种显式传参就不必了;写1.21及更低的话闭包捕获的还是同一个变量,踩过好几次。
-race怎么用,坑在哪
竞态检测内置在工具链里,加一个flag就行:
go run -race main.go go test -race -count=2 -cpu=1,4,8 ./... go build -race -o app_race ./cmd/app
前提是CGO_ENABLED=1,并且非Darwin平台要装C编译器。官方支持的平台是linux/amd64、linux/ppc64le、linux/arm64、linux/s390x、linux/loong64、freebsd/amd64、netbsd/amd64、darwin/amd64、darwin/arm64和windows/amd64,32位一律不支持。Windows上还多一道坎:自Go 1.21起,-race要求C编译器带的mingw-w64运行时不低于第8版,老版本的TDM-GCC直接编不过,甩一堆link错误出来。判断的办法是让编译器自己说:
gcc --print-file-name libsynchronization.a
新版会打印出这个库的完整路径,老版只会原样回显参数。回显了就去msys2或者winlibs换一份新的mingw-w64,实测在这一步卡住的人不少。
开销也得有心理准备。官方给的数字是内存涨5到10倍、执行时间涨2到20倍;另外每个defer和recover语句会额外占8字节,而且goroutine退出之前不回收,长跑的goroutine里循环用defer能把内存顶上去,这部分占用在runtime.ReadMemStats和pprof里还看不见。所以-race只放测试和预发,别上生产。
还有一点容易被误判:-race只报真正执行到的竞争。测试里压根没启goroutine,或者靠time.Sleep碰运气,跑一遍没报不代表安全。CI里固定带上这个flag,再配合-count多跑几轮,命中率会高很多。
四种修法怎么挑
sync.Mutex最省事,读写都独占,读写比例接近时其实不差。sync.RWMutex多给了一档读锁,多个goroutine能同时读,只有写的时候才独占,读多写少的缓存场景用它。sync.Map内部靠read和dirty两层结构,读命中read那层完全无锁,代价是key和value都是interface{},没有类型约束,也没有len(),Range还不保证是快照。channel那一路是把map关在一个goroutine里,外面只通过channel说话,看着最像是那么回事,但每次操作多一次调度,实测在高频路径上延迟会明显上去。
选型上给个直接的说法:启动时写一次、之后基本只读的配置表、路由表这类,用sync.Map;读写都有、要len、要遍历、要做check then set这类复合操作的,用原生map配sync.RWMutex。写多读少的场景里sync.Map比加锁的普通map还慢,别被「并发安全」四个字带偏。之前见过把请求计数器换成sync.Map之后QPS掉了一截的,就撞在这上头。
几个容易漏的地方
map被包在struct里、只通过方法访问,锁却加在了外层调用方而不是全部访问路径上,只要有一条路径没锁住就白搭;range遍历的同时有goroutine在写,报的是concurrent map iteration and map write;结构体赋值时map字段只复制了引用,拷出来的两个结构体共享同一份底层数据;还有把sync.Map当普通map用,在Range的回调里又去Store,逻辑绕成一团。
改完别急着收工,把go test -race跑干净,最好固定进CI。这个报错一旦在业务高峰期复现,代价比多跑几分钟测试高得多。数据截至2026年10月。