53.9 竞态检测与并发测试
一句话
竞态检测不是“跑一次
-race”,而是用调度压力把偶发交错放大:-race只在交错真的发生时报告,所以并发测试要重复、要并行、要控制时钟,并且把检测放进每次 PR。
先破除五个误区
| 误区 | 现实 |
|---|---|
| “测试过了就没有竞态” | 竞态是概率事件。单次执行只覆盖一种交错,没报不等于没有 |
“-race 能证明无竞态” | 只能证明“本次执行没观察到”。休眠的竞态可能几千次才触发一次 |
| “并发测试要 sleep 等一下” | sleep 既慢又不保证:等短了失败,等长了拖慢套件;要用 channel/WaitGroup 或 synctest |
“-race 太慢,放夜间就行” | 开销确实大(官方数据:执行时间 2–20 倍),但竞态漏到线上的修复成本更高;PR 上至少跑核心包 |
| “加锁就万事大吉” | 锁粒度、锁顺序、复制含锁的值(go vet 的 copylocks)都会引入新问题 |
三层检测,各抓什么
| 层 | 命令 | 抓什么 | 放在哪 | 成本 |
|---|---|---|---|---|
| 数据竞争 | go test -race ./... | 读写冲突(happens-before 违例) | 每次 PR | 2–20 倍 |
| 调度压力 | go test -race -count=100 -cpu=1,2,4 | 偶发交错、顺序依赖 | 夜间 | 分钟到小时 |
| 确定性并发 | testing/synctest(Go 1.25+) | 时间/超时/竞态窗口 | 每次 PR | 毫秒级 |
| 泄漏/死锁 | -timeout、goleak | goroutine 泄漏、永久阻塞 | PR + 夜间 | 低 |
原理(happens-before、影子内存)见 15.7 竞态检测与数据竞争;这里只讲测试怎么编排、门禁怎么放。
先看一个真实的竞态
package race
type Counter struct {
n int
}
func (c *Counter) Inc() { c.n++ }
func (c *Counter) Value() int { return c.n }
package race
import (
"sync"
"testing"
)
func TestCounterParallel(t *testing.T) {
var c Counter
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
c.Inc()
}
}()
}
wg.Wait()
if c.Value() != 8000 {
t.Fatalf("got %d, want 8000", c.Value())
}
}
$ go test -race -run TestCounterParallel
==================
WARNING: DATA RACE
Read at 0x00c00019a1a8 by goroutine 14:
example.com/race.(*Counter).Inc()
counter.go:7 +0x90
example.com/race.TestCounterParallel.func1()
counter_test.go:16 +0x8c
Previous write at 0x00c00019a1a8 by goroutine 9:
example.com/race.(*Counter).Inc()
counter.go:7 +0xa0
example.com/race.TestCounterParallel.func1()
counter_test.go:16 +0x8c
==================
--- FAIL: TestCounterParallel
FAIL
读报告只看三件事:冲突的变量/行号(counter.go:7)、两个 goroutine 的创建栈(都在 TestCounterParallel 里)、读/写类型。不要看地址和 goroutine 编号,它们每次都变。
修法不是“加 sleep 错开”,而是让访问有 happens-before 关系:
package race
import "sync/atomic"
type Counter struct {
n atomic.Int64
}
func (c *Counter) Inc() { c.n.Add(1) }
func (c *Counter) Value() int64 { return c.n.Load() }
$ go test -race -count=10 ./...
ok example.com/race 1.344s
-count=10 重复 10 轮、-race 全程开启仍全绿,才有一点“没观察到竞态”的信心;单次通过说明不了什么。
时间相关并发:用假时钟,不要 sleep
testing/synctest 把测试放进一个“气泡”:time 变成假时钟,只有所有 goroutine 都持久阻塞时才推进。30 秒的防抖可以用 1 分钟的假等待测完,实际耗时 0.00 秒。
package synctest
import "time"
// Debounce:d 内没有再次调用才真正执行 f。
func Debounce(d time.Duration, f func()) (stop func()) {
t := time.AfterFunc(d, f)
return func() { t.Stop() }
}
package synctest
import (
"testing"
"testing/synctest"
"time"
)
func TestDebounceFiresOnce(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
calls := 0
stop := Debounce(30*time.Second, func() { calls++ })
defer stop()
// 气泡里的 time 是假时钟:Sleep 1 分钟不需要真的等。
time.Sleep(time.Minute)
if calls != 1 {
t.Fatalf("calls=%d, want 1", calls)
}
})
}
$ go test -c -o synctest.test . && ./synctest.test -test.v
=== RUN TestDebounceFiresOnce
--- PASS: TestDebounceFiresOnce (0.00s)
PASS
三条边界:气泡里不要访问网络/外部进程;goroutine 必须在气泡内启动;t.Run/t.Parallel/t.Deadline 在气泡里不可用。
压力测试:把“偶发”变成“可复现”
| 手段 | 命令 | 作用 |
|---|---|---|
| 重复 | -count=100 | 同一用例跑 100 遍,放大调度差异 |
| 改并行度 | -cpu=1,2,4 | 不同 GOMAXPROCS 下的交错不同 |
| 顺序打乱 | -shuffle=on | 抓测试间的顺序依赖 |
| 超时 | -timeout=30s | 死锁时快速失败并打印所有 goroutine 栈 |
| 并行子测试 | t.Parallel() | 让独立用例真正并发,暴露共享状态 |
组合示例(夜间跑):
go test -race -count=100 -cpu=1,2,4 -shuffle=on -timeout=5m ./...
按单个用例 2 秒算,100 轮 × 3 种 GOMAXPROCS ≈ 10 分钟;这就是它放夜间的理由,也是 52.4 流水线设计与耗时优化 里“高频轻量、低频重量”的分层。
并发测试的写法:先同步,再断言
// 起跑线:所有 goroutine 就绪后一起放行,最大化交错
start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
// 被测操作
}()
}
close(start)
wg.Wait()
// 只在 join 之后断言最终状态;不要断言中间态
三个要点:
- 用 channel/WaitGroup 做同步,不用
sleep猜时间。 - 断言最终不变量(总数、余额、状态),不要断言“执行到第几步”。
- 每个测试自己造数据(唯一前缀),避免并行测试互相污染(见 53.7 测试数据管理)。
生产落地:指名 → 可抄 → 可验 → 可退
指名:Go 自带 -race + testing/synctest;goroutine 泄漏用 go.uber.org/goleak;死锁用 -timeout + goroutine dump。替代:-race 在部分平台需要 CGO,交叉编译/精简镜像要先确认可用。
可抄(Makefile / CI):
test-race:
go test -race -timeout=5m ./...
test-stress:
go test -race -count=100 -cpu=1,2,4 -shuffle=on -timeout=10m ./...
// TestMain 里检查 goroutine 泄漏(goleak,本机未安装验证)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
可验:上面的 buggy 版本能稳定复现 WARNING: DATA RACE;改成 atomic 后 -race -count=10 全绿;synctest 用例显示 PASS (0.00s)。三件事都跑过,才叫验证。
可退:
-race让流水线超时:先只对核心包开,或拆独立 job 并行;不要直接关掉。- 压力测试发现偶发失败但无法定位:用例进 quarantine,保留
-race阻断,开 ticket + owner。 - synctest 不兼容(Go 版本低/依赖真实网络):退回“channel 同步 + 短超时”,不要用 sleep。
反模式
| 反模式 | 后果 |
|---|---|
用 sleep 等 goroutine | 慢且不稳:CI 慢时失败,本地快时通过 |
只跑一次 -race 就宣布安全 | 休眠竞态被放过 |
把 WARNING: DATA RACE 当 flaky 重跑 | 真 bug 被掩盖,迟早线上炸 |
| 压力测试放 PR | 几十分钟,团队绕过 |
| 并行测试共享全局状态 | 随机失败,无法复现 |
| 只加锁不看锁顺序 | 死锁替代竞态 |
| 断言中间状态 | 调度一变就红,测试绑死实现 |
| 无超时 | 死锁时 CI 挂到天荒地老 |
自检
Q1 go test -race 通过,能否说明代码没有数据竞争?
▸ 答案:不能。-race 是动态检测:只有本次执行中真的发生了冲突访问才会报告。没触发的原因可能是调度没交错、代码路径没走到、压力不够。它能把“已发生的竞态”变成确定性失败,但不能证明“不存在竞态”。所以还要 -count、-cpu、-shuffle 施压,并对关键并发逻辑做设计审查。
Q2 竞态报告里最该看什么?goroutine 编号和地址有用吗?
▸ 答案:看三样:冲突的变量与代码行(counter.go:7)、读/写两侧的调用栈(谁在什么路径上访问)、goroutine 的创建位置(哪段代码起的)。地址和 goroutine 编号每次运行都变,对定位没有价值。报告里“Previous write”说明已经有一次写发生在先,重点是让这两次访问建立 happens-before(锁、channel、atomic)。
Q3 为什么用 synctest 而不是 time.Sleep 测超时/防抖?
▸ 答案:sleep 用真实时间:等短了偶发失败,等长了拖慢套件,而且无法稳定复现“刚好在边界”的行为。synctest 用假时钟,测试可以在 0.00 秒内跨过 30 秒/1 分钟,且推进时机由“所有 goroutine 持久阻塞”决定——既快又确定。边界是不能访问网络/外部进程,goroutine 要在气泡内启动。
Q4 -race -count=100 跑 10 分钟,值得放 PR 吗?
▸ 答案:通常不值得。PR 上放 -race 单轮(秒到分钟级),夜间放 -count=100 -cpu=1,2,4 的压力组合。分层依据是“反馈频率 × 成本”:高频门禁要快,低频门禁可以重。把压力测试塞进 PR,结果通常是超时后被 || true 绕过,等于没有。
Q5 并发测试断言最终状态,为什么不断言中间状态?
▸ 答案:中间状态依赖调度顺序,不同 GOMAXPROCS/机器会产生不同交错;断言它等于把某一种调度写进测试,稍微一变就红,而且这种红通常不是 bug。正确做法是用 channel/WaitGroup 等到 join,再断言最终不变量(总数、余额、状态);如果要验证中间过程,用可控的同步点(信号 channel)而不是时间。