Go 用 -race 抓数据竞争:一个偶发崩溃的排查、原理与修复
你的 Go 服务在本地跑得好好的,压测或上线后偶尔崩一下,报个 fatal error: concurrent map read and map write,或者更诡异------某个计数器统计出来的值总比实际少一点,重启又好了。这类"偶发、难复现、和并发有关"的 bug,十有八九是数据竞争(data race):两个 goroutine 同时访问同一块内存,其中至少一个是写,且没有同步。
好消息是:Go 自带一个几乎能"当场抓现行"的工具------竞态检测器,加个 -race 就行。这篇讲怎么用它定位,为什么会有竞争,以及怎么修。
先写一段有竞争的代码
一个经典场景:并发地给一个计数器累加。
go
package main
import (
"fmt"
"sync"
)
func main() {
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.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 当场抓现行
不用你去猜,直接开检测器跑:
bash
go run -race main.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(计数器这种简单场景,最快)。
go
import "sync/atomic"
var counter int64
// ...
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1) // 原子加,无竞争
}()
// 读取:atomic.LoadInt64(&counter)
Go 1.19+ 还有更好用的类型化原子:
go
var counter atomic.Int64
counter.Add(1)
fmt.Println(counter.Load())
原子操作适合"单个数值的读写",开销比锁小。
修法二:用 sync.Mutex(要保护一段逻辑、多个字段时)。
go
var (
mu sync.Mutex
counter int
)
go func() {
defer wg.Done()
mu.Lock()
counter++ // 临界区里独占访问
mu.Unlock()
}()
当你要保护的不是一个数,而是"一组相关操作"(比如同时改 map 的多个 key、或读一个字段再据此写另一个),用互斥锁。
修法三:干脆不共享,用 channel 把结果收拢。 有时最好的修法是从设计上消除共享内存:
go
results := make(chan int, 1000)
for i := 0; i < 1000; i++ {
go func() { results <- 1 }()
}
counter := 0
for i := 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 崩溃(不是悄悄出错,是当场挂):
go
m := map[string]int{}
for i := 0; i < 100; i++ {
go func(k int) {
m[fmt.Sprint(k)] = k // fatal error: concurrent map writes
}(i)
}
-race 同样能抓到它。修法:要么加锁包一层,要么用 sync.Map(读多写少场景):
go
var m sync.Map
m.Store("key", 42)
v, ok := m.Load("key")
一般业务里"读多写少 + key 集合稳定"用 sync.Map;写频繁、或需要范围操作,用 map + sync.RWMutex 更可控。
关键:怎么把 -race 用在真实项目里
-race 只报"这次运行实际发生的"竞争,不会静态分析出所有潜在竞争。所以正确用法是让它尽可能多地跑到并发路径:
在测试里带 -race 跑,并且加压。
bash
go test -race ./...
如果某个测试是串行的,竞争可能根本不触发。用 -race 配合并行/多次运行能提高命中率:
bash
# 让并行测试真正并行,并把可竞争的路径多跑几遍
go test -race -count=5 -parallel 8 ./...
在 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,让竞争在合并前而不是半夜告警时暴露。