11.3 用 -race 发现竞态
11.1 节和 11.2 节里,「用 -race 验证」出现了很多次,但一直没解释它到底是什么。这一节补齐。
先说结论:-race 是 Go 工具链里性价比最高的一个开关。它不需要你改一行代码,只要在命令上加一个 flag,就能把「只在特定时序下出现、测试环境永远复现不了」的数据竞态变成一份带文件名和行号的报告。并发代码没有 -race 跑过,就不算验证过。
本节把 TaskAPI 推进到:为并发安全 store 补上一组能在
-race下通过的并发测试,并把这套验证方式固定进开发流程。
11.3.1 race detector 是什么
它不是采样分析器,而是编译期插桩 + 运行时检测:
- 加
-race编译时,编译器会在每一次内存读写、每一次同步操作(锁、channel、atomic)前后插入检测代码。 - 运行时,检测器维护每个内存位置最近的访问记录和一组「happens-before」关系。
- 当它发现「两个 goroutine 访问同一地址、至少一个是写、且两者之间没有任何同步关系」时,立刻打印报告。
关键点是第 3 条里的 happens-before。它检查的不是「有没有加锁」这个表面形式,而是「这两次访问之间有没有建立同步顺序」。所以下面这些都算建立了 happens-before:
- 对同一把
Mutex的Unlock早于后续的Lock; - 向 channel 的发送早于对应的接收;
wg.Done()早于Wait()返回;- 对同一变量的
atomic.Store早于后续的atomic.Load。
只要建立了顺序,检测器就认为安全。反之,哪怕代码看起来「碰巧不会冲突」,它也会报。
11.3.2 三种用法
# 直接跑
GOTOOLCHAIN=go1.27.0 go run -race .
# 编译出一个带检测的二进制
GOTOOLCHAIN=go1.27.0 go build -race -o taskapi-race .
# 跑测试(最常用)
GOTOOLCHAIN=go1.27.0 go test -race ./...
go test -race 是日常主力:测试里本来就有并发场景,加上 -race 就等于每次跑测试都顺带做一次并发体检。
两个运行时行为需要知道:
- 检测到竞态时,进程以退出码 66 结束(不是 1)。CI 里如果只判断「非 0 即失败」没问题,但如果有脚本精确匹配退出码,要记得这个数字。
- 环境变量
GORACE可以调整行为,例如GORACE="halt_on_error=1"让检测到第一个竞态就立刻终止(默认会继续跑完并汇总所有竞态)。
11.3.3 逐行读一份真实报告
下面这段程序有一个典型的竞态——count++ 没有同步:
package main
import (
"fmt"
"sync"
)
func main() {
count := 0
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Go(func() {
count++
})
}
wg.Wait()
fmt.Println("count =", count)
}
GOTOOLCHAIN=go1.27.0 go run -race . 的实测输出:
==================
WARNING: DATA RACE
Read at 0x00c000012118 by goroutine 8:
main.main.func1()
main.go:13 +0x2c
Previous write at 0x00c000012118 by goroutine 7:
main.main.func1()
main.go:13 +0x3c
Goroutine 8 (running) created at:
sync.(*WaitGroup).Go()
Goroutine 7 (finished) created at:
sync.(*WaitGroup).Go()
==================
count = 4
Found 2 data race(s)
exit status 66
逐段解读:
| 片段 | 含义 |
|---|---|
WARNING: DATA RACE | 一对冲突访问,一个报告块 = 一次冲突 |
Read at 0x...118 by goroutine 8 | 冲突的一方:读,地址,goroutine 编号 |
main.go:13 | 直接指到出问题的源码行,这是报告最有价值的部分 |
Previous write at ... by goroutine 7 | 冲突的另一方:写,以及是谁写的 |
created at: sync.(*WaitGroup).Go() | 两个 goroutine 各自的出生点 |
Found 2 data race(s) | 一共发现 2 对冲突 |
exit status 66 | 检测到竞态的固定退出码 |
注意最后一行 count = 4:这次运行的结果恰好是对的。四个 goroutine 各加一次,count 是 4,看起来毫无问题。但检测器依然报了 2 对冲突——因为它看的是 happens-before 关系,而不是结果对不对。
这正是数据竞态最危险的地方:没有 -race,你根本无法判断「结果对」是因为代码正确,还是因为运气好。
11.3.4 修复它
两种修法,取决于语义。
方案一:用 atomic(单个计数器,最轻):
var count atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Go(func() { count.Add(1) })
}
wg.Wait()
fmt.Println("count =", count.Load())
实测输出(go run -race,无任何报告):
count = 4
方案二:用 Mutex(如果 count 只是临界区里的一小部分逻辑)。选择依据在 11.1.7 的选型表里:单个变量用 atomic,多变量一致性用锁。
修完之后,一定要用 -race 再跑一遍确认报告消失。有时候修复引入的锁本身用错了(比如复制了 Mutex),-race 同样能抓出来。
11.3.5 代价:-race 有多慢
检测器要记录每次访问和同步事件,开销不可能为零。实测两个点:
| 场景 | 无 -race | 有 -race | 倍数 |
|---|---|---|---|
| 紧循环并发读 map | 3.48 ns/op | 416.7 ns/op | 约 120 倍 |
| 4 worker 做小段计算 | 3082 ns/op | 8215 ns/op | 约 2.7 倍 |
(Apple M1 Pro,go1.27.0)
第一个数字很吓人,但它对应的是「几乎不干实事、纯并发访问内存」的极端微基准——这种负载下检测器的相对开销最大。第二个更接近真实程序:大约 2~3 倍,业务逻辑越重、内存访问越稀,倍数越低。
另外检测器本身要占内存(每个内存位置都要记录历史),大程序上可能多占几倍到十几倍的堆。
实践建议:
- 开发时:
go test -race ./...当默认动作,慢一点无所谓。 - 压测/性能验证时:关掉
-race,否则测出来的数字没有参考价值。 - 生产环境:不要开。它不是为线上设计的。
11.3.6 盲区:-race 查不出什么
-race 很强,但不是万能。它只能发现「在本次运行中实际发生」的竞态。下面这些情况它抓不到:
| 情况 | 为什么抓不到 |
|---|---|
| 有竞态但这次没触发 | 检测器只看到实际执行的访问 |
| 测试用例没覆盖并发路径 | 没跑到的代码不会被检查 |
| 死锁 | 死锁是「卡住」不是「冲突」,要用超时或栈 dump 排查 |
| 逻辑错误(非竞态) | 比如锁的顺序不一致导致的数据不一致 |
用了 unsafe / sync/atomic 绕过 | 检测器信任 atomic 建立的顺序 |
所以正确的心态是:-race 报告 = 有竞态(确定);-race 无报告 ≠ 无竞态(只是这次没出现)。
要提高覆盖率,办法是让测试主动制造并发:多个 goroutine 同时读写、循环多次、用 -count 重复跑:
GOTOOLCHAIN=go1.27.0 go test -race -count=10 -run TestMemStore ./...
-count=10 会让每个测试重复执行 10 次,用重复次数去逼近那些「偶发时序」。
11.3.7 把 -race 固定进工作流
三个落点,按优先级排:
- 本地提交前:把
go test -race ./...写进Makefile或go generate脚本里,别靠记性。 - CI 流水线:单独一个 job 跑
go test -race ./...,与普通测试分开,这样它的耗时不影响主流水线。 - 代码评审清单:任何涉及
go、chan、sync、atomic的 PR,评审时必须确认作者跑过-race。
TaskAPI 后续每次新增并发组件,都要同步补一个能在 -race 下通过的测试——这是第 8 章表驱动测试思想在并发场景的延续。
11.3.8 TaskAPI 的并发测试
给 11.1 节的 MemStore 补一个并发测试:
package store
import (
"fmt"
"sync"
"testing"
)
func TestMemStoreConcurrent(t *testing.T) {
s := NewMemStore()
var wg sync.WaitGroup
for i := int64(1); i <= 200; i++ {
wg.Go(func() {
s.Save(Task{ID: i, Title: fmt.Sprintf("任务%d", i)})
if _, ok := s.Get(i); !ok {
t.Errorf("任务 %d 写入后读不到", i)
}
})
}
wg.Wait()
}
跑法:
GOTOOLCHAIN=go1.27.0 go test -race -v ./...
实测输出:
=== RUN TestMemStoreConcurrent
--- PASS: TestMemStoreConcurrent (0.00s)
PASS
ok taskapi 1.492s
把这个测试里 store 的锁去掉,同样的命令立刻变成:
WARNING: DATA RACE
Write at 0x00c0001206c0 by goroutine 9:
runtime.mapaccess2_fast64()
taskapi.(*MemStore).Save()
store.go:14 +0xa4
taskapi.TestMemStoreConcurrent.func1()
store_test.go:14 +0x38
Previous write at 0x00c0001206c0 by goroutine 10:
...
fatal error: concurrent map writes
FAIL taskapi 0.685s
这里同时出现了竞态报告和 fatal error:map 的并发写保护先触发了致命错误。这也说明同一份代码可能以不同方式暴露问题——有时是 -race 报告,有时是直接崩溃。
一个写法上的提醒:测试里的 goroutine 不要调用 t.Fatalf(它只能在测试主 goroutine 里调用),用 t.Errorf 记录失败即可;-race 的报告是独立于 t 的输出,不受影响。
11.3.9 小结与练习
-race是编译期插桩的 happens-before 检测器,不是采样器。- 报告里的
main.go:13是定位问题的关键,exit status 66是它的固定退出码。 - 结果正确不代表没有竞态;只有
-race能给出判断依据。 - 开销约 2~3 倍(真实负载),紧循环微基准可达百倍;生产环境不要开。
-race无报告不等于无竞态,用-count提高重复次数、主动制造并发来提升覆盖率。
练习:
- 把 11.3.3 的程序里
count++改成count += 1、count = count + 1,观察-race是否都能抓到(提示:都能,因为本质都是读改写)。 - 用
GORACE="halt_on_error=1"重跑,观察输出与默认行为有何不同。 - 给自己写过的任意一段并发代码加上
-race -count=10,看能不能跑出报告。 - 思考:为什么
wg.Done()与Wait()之间能建立 happens-before,而两个wg.Go里的count++之间不能?
下一章我们进入 context:让取消信号沿着调用链传播,让 TaskAPI 能在 Ctrl-C 之后优雅退出。
阅读导航:上一节:11.2 WaitGroup/Once/sync.Map · 下一节:12.1 context 的取消与传播 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。