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 之后断言最终状态;不要断言中间态

三个要点:

  1. 用 channel/WaitGroup 做同步,不用 sleep 猜时间。
  2. 断言最终不变量(总数、余额、状态),不要断言“执行到第几步”。
  3. 每个测试自己造数据(唯一前缀),避免并行测试互相污染(见 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)而不是时间。

关联