Go 用 -race 抓数据竞争:一个偶发崩溃的排查、原理与修复
你的 Go 服务在本地跑得好好的,压测或上线后偶尔崩一下,报个fatal error: concurrent map read and map write,或者更诡异——某个计数器统计出来的值总比实际少一点,重启又好了。这类"偶发、难复现、和并发有关"的 bug,十有八九是数据竞争(data race):两个 goroutine 同时访问同一块内存,其中至少一个是写,且没有同步。
好消息是:Go 自带一个几乎能"当场抓现行"的工具——竞态检测器,加个-race就行。这篇讲怎么用它定位,为什么会有竞争,以及怎么修。
先写一段有竞争的代码
一个经典场景:并发地给一个计数器累加。
packagemainimport("fmt""sync")funcmain(){counter:=0varwg sync.WaitGroupfori:=0;i<1000;i++{wg.Add(1)gofunc(){deferwg.Done()counter++// 1000 个 goroutine 同时读改写同一个变量}()}wg.Wait()fmt.Println("counter =",counter)}你期望输出counter = 1000。但实际跑几次:
counter = 981 counter = 993 counter = 1000 counter = 967时对时错。原因是counter++不是原子操作,它其实是三步:读取 counter → 加 1 → 写回。两个 goroutine 可能同时读到42,各自加到43,都写回43——本该是44,少算了一次。这就是数据竞争,而且它不一定崩溃,更多时候是"结果悄悄错了",比崩溃还难查。
用 -race 当场抓现行
不用你去猜,直接开检测器跑:
go run-racemain.go输出会明确指出竞争发生在哪、哪两个 goroutine、分别在读还是写:
================== WARNING: DATA RACE Read at 0x00c0000b4010 by goroutine 8: main.main.func1() /path/main.go:16 +0x... Previous write at 0x00c0000b4010 by goroutine 7: main.main.func1() /path/main.go:16 +0x... ... Found 1 data race(s) exit status 66信息量很大:
- 内存地址
0x00c0000b4010:同一块内存被两个 goroutine 碰了。 - Read at … / Previous write at …:一个在读、一个在写,时间上重叠。
- 精确到文件:行号(
main.go:16)和 goroutine 编号。
-race的原理是在编译时给每次内存访问插桩,运行时记录"哪个 goroutine 在什么时间、用什么同步关系访问了哪块内存",一旦发现两次访问之间没有 happens-before 关系且至少一次是写,就报警。所以它只能报"实际发生过的"竞争——某次运行没触发到的竞争,这次就不会报。这点很重要,决定了怎么用它(见下文)。
三种修法,按场景选
修法一:用sync/atomic(计数器这种简单场景,最快)。
import"sync/atomic"varcounterint64// ...gofunc(){deferwg.Done()atomic.AddInt64(&counter,1)// 原子加,无竞争}()// 读取:atomic.LoadInt64(&counter)Go 1.19+ 还有更好用的类型化原子:
varcounter atomic.Int64 counter.Add(1)fmt.Println(counter.Load())原子操作适合"单个数值的读写",开销比锁小。
修法二:用sync.Mutex(要保护一段逻辑、多个字段时)。
var(mu sync.Mutex counterint)gofunc(){deferwg.Done()mu.Lock()counter++// 临界区里独占访问mu.Unlock()}()当你要保护的不是一个数,而是"一组相关操作"(比如同时改 map 的多个 key、或读一个字段再据此写另一个),用互斥锁。
修法三:干脆不共享,用 channel 把结果收拢。有时最好的修法是从设计上消除共享内存:
results:=make(chanint,1000)fori:=0;i<1000;i++{gofunc(){results<-1}()}counter:=0fori:=0;i<1000;i++{counter+=<-results// 只有 main 一个 goroutine 在改 counter}Go 的哲学"不要通过共享内存来通信,而要通过通信来共享内存"说的就是这个——让一个 goroutine 独占某份数据,别人通过 channel 交互,竞争自然消失。
改完任意一种后再go run -race main.go,WARNING: DATA RACE消失,counter稳定为 1000。
并发 map 是重灾区
比计数器更常见的翻车点是并发写 map。map 在 Go 里不是并发安全的,并发读写会直接fatal error崩溃(不是悄悄出错,是当场挂):
m:=map[string]int{}fori:=0;i<100;i++{gofunc(kint){m[fmt.Sprint(k)]=k// fatal error: concurrent map writes}(i)}-race同样能抓到它。修法:要么加锁包一层,要么用sync.Map(读多写少场景):
varm sync.Map m.Store("key",42)v,ok:=m.Load("key")一般业务里"读多写少 + key 集合稳定"用sync.Map;写频繁、或需要范围操作,用map + sync.RWMutex更可控。
关键:怎么把 -race 用在真实项目里
-race只报"这次运行实际发生的"竞争,不会静态分析出所有潜在竞争。所以正确用法是让它尽可能多地跑到并发路径:
在测试里带-race跑,并且加压。
gotest-race./...如果某个测试是串行的,竞争可能根本不触发。用-race配合并行/多次运行能提高命中率:
# 让并行测试真正并行,并把可竞争的路径多跑几遍gotest-race-count=5-parallel8./...在 CI 里常态化开-race。数据竞争是"这次没触发不代表没有"的 bug,最好每次 CI 都用-race跑测试,让它在合并前就把新引入的竞争抓出来。
两个注意事项:
-race会让程序变慢约 2~10 倍、内存涨 5~10 倍,因为要给每次内存访问插桩。所以它用于测试和 CI,别把-race编进生产二进制。- 它只报运行中真实发生的竞争。你的测试没覆盖到的并发路径,它抓不到——所以竞争检测的效果,取决于你的测试有没有真的把并发跑起来。
小结
- 数据竞争:多个 goroutine 并发访问同一内存、至少一个是写、且无同步。表现为偶发崩溃或"结果悄悄算错",极难复现。
go run/test -race给内存访问插桩,当场报出竞争的内存地址、读/写方和精确行号,是定位并发 bug 的第一工具。- 修法三选一:简单数值用
sync/atomic;保护一段逻辑用sync.Mutex;能从设计上消除共享就用 channel 让单 goroutine 独占。 - 并发 map会直接 fatal 崩溃,用
sync.Map或map + RWMutex。 - 用法:
go test -race ./...进 CI 常态化;-race有性能开销,只用于测试、别进生产;它只报"实际跑到"的竞争,所以测试要真的把并发压起来。
一句话记住:并发代码没跑过-race就等于没测过;把它塞进 CI,让竞争在合并前而不是半夜告警时暴露。