《Go 语言高级编程》3.1 testing/synctest 确定性并发测试

并发测试里那些 time.Sleep(100*time.Millisecond) 既慢又脆。Go 1.25 的 testing/synctest 用假时钟把「等一秒」变成「瞬间推进」。本节实测假时钟跳过一小时、Wait 观察 goroutine 阻塞态、泄漏 goroutine 直接 panic,并给出 1.24 实验版到 1.25 正式版的 API 演进。

3.1 testing/synctest 确定性并发测试

如果你写过并发测试,大概率写过 time.Sleep(100 * time.Millisecond) 这种「等它一会儿」的代码。它既慢又脆,而且不确定:同一个测试今天过明天挂,你只能把 sleep 时间一加再加。

本节要回答:synctest 怎么让并发测试变确定、synctest.Wait 的语义到底是什么、哪个版本引入?结论:testing/synctest 于 Go 1.25 成为正式包(api/go1.25.txt),在 Go 1.24 是 GOEXPERIMENT=synctest 的实验特性且 API 为 Run;实测假时钟瞬间推进一小时,且泄漏的 goroutine 会让测试直接 panic。

典型的脆弱写法长这样:

go worker()
time.Sleep(100 * time.Millisecond) // 「等它一会儿」
if !done { t.Fatal("timeout") }

两个毛病:第一,它慢——每个测试真的等 100ms,一个文件几十个测试就是好几秒;第二,它脆——CI 机器一卡,worker 没跑完,测试就红了;机器太快,100ms 又显得浪费。Go 1.25 的 testing/synctest 给了这个问题一个根本解:把「时间」本身变成可控的。

3.1.1 bubble:一个隔离的测试宇宙

synctest.Test(t, func(t *testing.T){...}) 在一个 bubble(气泡)里运行测试函数。bubble 有三条关键规则:

  • bubble 里启动的 goroutine 都属于这个 bubble;
  • bubble 里的 time 包用的是假时钟,初始时间是 2000-01-01 UTC 午夜;
  • 假时钟只在 bubble 内所有 goroutine 都「持久阻塞」时才前进。

第三条是理解整个包的关键。「持久阻塞」(durably blocked)意味着该等待只能由 bubble 内部可追踪的事件解除。例如 time.Sleep、在 bubble 内创建的 channel 上阻塞、sync.Cond.Wait,以及符合 bubble 关联条件的 WaitGroup.Wait。真实网络 I/O、系统调用、Mutex/RWMutex 的锁等待不属于持久阻塞;channel 也不能省略“在 bubble 内创建”这一条件。参见 官方阻塞规则 。

3.1.2 实测:假时钟瞬间推进一小时

package a31

import (
	"testing"
	"testing/synctest"
	"time"
)

func TestFakeClock(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		start := time.Now()
		time.Sleep(time.Hour) // 假时钟:瞬间推进
		if d := time.Since(start); d != time.Hour {
			t.Fatalf("want 1h, got %v", d)
		}
	})
}

实测输出(GOTOOLCHAIN=go1.27.0 go test -v .):

=== RUN   TestFakeClock
--- PASS: TestFakeClock (0.00s)

time.Sleep(time.Hour) 在测试里耗时 0.00 秒,但 time.Since(start) 精确等于一小时。这不是「跳过 sleep」,而是时钟真的被推到了未来——所有依赖时间的逻辑(超时、退避、定时器)都会按真实语义执行,只是不花真实时间。

3.1.3 synctest.Wait 的语义

synctest.Wait() 常被误解成「等一会儿」。它真正做的是:阻塞当前 goroutine,直到 bubble 内其它所有 goroutine 都进入持久阻塞。注意——它不推进时钟。

这带来一个微妙但重要的用法:可以用 Wait 观察某个 goroutine 是否已经走到「等事件」的状态,而不会让时间前进。

// 接续前面的测试文件,并在 import 块补上 "sync/atomic"。
func TestWaitObservesState(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		var stage atomic.Int32
		go func() {
			stage.Store(1)
			time.Sleep(time.Second)
			stage.Store(2)
		}()
		synctest.Wait() // 等所有 goroutine 持久阻塞;时间不推进
		if got := stage.Load(); got != 1 {
			t.Fatalf("before advancing time, stage=%d", got)
		}
		time.Sleep(2 * time.Second) // 全体阻塞 → 假时钟跳到 1s
		synctest.Wait()
		if got := stage.Load(); got != 2 {
			t.Fatalf("after advancing time, stage=%d", got)
		}
	})
}

实测输出:

=== RUN   TestWaitObservesState
--- PASS: TestWaitObservesState (0.00s)

第一次 Wait 后 stage == 1(goroutine 已进入 Sleep 但时间没动);主 goroutine 自己 Sleep(2s) 时,bubble 内全体阻塞,假时钟跳到 1s,goroutine 醒来把 stage 设为 2。这就是把「竞态」变成「确定时序」的写法:每一步状态都精确可预测。

3.1.4 实测:超时场景

超时是最需要确定性的场景。让被等待的操作花 30 秒,而超时设 5 秒:

func TestTimeoutWins(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		stop := make(chan struct{})
		done := make(chan struct{})
		go func() {
			select {
			case <-time.After(30 * time.Second):
				close(done)
			case <-stop:
			}
		}()
		select {
		case <-done:
			t.Fatal("should not finish first")
		case <-time.After(5 * time.Second):
			t.Log("timeout fired at fake 5s")
		}
		close(stop)
		synctest.Wait()
	})
}

实测输出:

=== RUN   TestTimeoutWins
    synctest_test.go:55: timeout fired at fake 5s
--- PASS: TestTimeoutWins (0.00s)

超时逻辑在假 5 秒处触发,整个测试 0.00 秒跑完。普通测试采用相同 select 时会真实等待约 5 秒;synctest 把这段等待转换为假时钟推进,并保留超时分支的语义。

3.1.5 泄漏的 goroutine 会让测试 panic

synctest 有一个我特别喜欢的设计:bubble 结束时如果还有 goroutine 卡在持久阻塞上,测试直接 panic。故意写一个「泄漏」的测试:

func TestLeak(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		go func() { time.Sleep(10 * time.Second) }()
	})
}

实测输出(节选):

--- FAIL: TestLeak (0.00s)
panic: deadlock: main bubble goroutine has exited but blocked goroutines remain [recovered, repanicked]

这条 panic 把一个平时很难发现的 bug 变成了必然失败:一个忘了退出的后台 goroutine。在普通测试里,它可能悄无声息地活到进程结束;在 synctest 里,它立刻让测试红掉。它检查的是 bubble 内的退出与死锁条件;goleak(本卷 10.3 会讲)检查测试前后的 goroutine 存量,二者覆盖范围不同,不能互相等同。

3.1.6 版本归属:1.24 实验版 → 1.25 正式版

这里有一处必须讲清的版本演进,因为 1.24 与 1.25 的 API 不兼容:

版本状态API
Go 1.24GOEXPERIMENT=synctest 实验特性synctest.Run(f func())、synctest.Wait()
Go 1.25正式包,无需实验开关synctest.Test(t *testing.T, f func(*testing.T))、synctest.Wait()
Go 1.26 / 1.27沿用 1.25 的 Test API同 1.25

证据一:api/go1.25.txt 记录了 Test 与 Wait:

pkg testing/synctest, func Test(*testing.T, func(*testing.T)) #67434
pkg testing/synctest, func Wait() #67434

证据二:go1.24.0 的包文档明确写着它依赖实验开关,且只有 Run:

$ GOTOOLCHAIN=go1.24.0 GOEXPERIMENT=synctest go doc testing/synctest
This package only exists when using Go compiled with GOEXPERIMENT=synctest.
func Run(f func())
func Wait()

证据三:1.24 的 Run 版 API 在本机实测可跑通(go1.24.0 GOEXPERIMENT=synctest go test → --- PASS: TestOldAPI),1.25+ 上同样的 Run 代码不再存在。所以从 1.24 升到 1.25 时,synctest.Run 的调用必须改写成 synctest.Test,这是升级清单上的一条。

3.1.7 使用纪律

synctest 强大,但有明确的使用边界(文档也列了):

纪律原因
不要在 bubble 里访问网络真实网络事件会打破假时钟的可预测性,用 fake 实现代替
不要与 bubble 外的 goroutine 交互外部 goroutine 不属于 bubble,阻塞关系无法被追踪
不要依赖外部进程同上,进程的时序不受假时钟控制
不要留后台 goroutinebubble 结束时会 panic
测试要自包含假时钟初始时间固定为 2000-01-01,跨 bubble 不共享状态

一个实际建议:把 synctest 用在纯逻辑的并发单元上——worker pool、重试退避、超时控制、限流器。这些代码的价值恰恰在于「时间相关的正确性」,而 synctest 正是为它们设计的。

再补一条常被忽略的性质:bubble 内的 time 是假的,但 runtime 的其它部分是真的。runtime.Gosched、channel、sync.Mutex 都照常工作,只有「时间」被替换。因此,隔离好外部依赖之后,可以测试 time.After、time.NewTicker、context.WithTimeout 等时间逻辑;多个同时就绪的 select 分支及 goroutine 调度仍可能有不同执行顺序。

测试对象普通测试用 synctest
超时触发时机靠真实等待,脆且慢假时钟精确控制
重试退避序列累积等待数秒瞬间推进
定时器生命周期检查 Stop、取消与业务状态假时钟辅助断言,不会仅因残留定时器而自动 panic
bubble 内 goroutine 退出等待退出或使用额外工具根 goroutine 退出后仍有持久阻塞的 goroutine,会报死锁
CPU 密集逻辑照常照常(不属于「时间」)

最后一句提醒:synctest 不能替代 -race。它管的是「时间与阻塞关系」,数据竞争仍要靠 go test -race(卷一 10–12 章讲过用法)。两者是互补的:-race 抓并发读写错误,synctest 抓时序与泄漏。

3.1.8 小结

  • testing/synctest 于 Go 1.25 成为正式包(api/go1.25.txt,#67434),1.24 是 GOEXPERIMENT=synctest 且 API 为 Run。
  • 升级清单:1.24 → 1.25 时,synctest.Run(f) 必须改写为 synctest.Test(t, func(t *testing.T){...}),这是 API 不兼容的一次演进。
  • bubble 内的 time 是假时钟,只在全体 goroutine 持久阻塞时前进;实测 time.Sleep(time.Hour) 耗时 0.00 秒但 time.Since 精确为一小时。
  • synctest.Wait() 等的是「所有 goroutine 持久阻塞」,不推进时钟,因此可以用来观察中间状态。
  • bubble 结束时残留阻塞 goroutine 会 panic(deadlock: ... blocked goroutines remain),把 goroutine 泄漏变成必然失败。
  • 边界:不与网络、外部进程、bubble 外的 goroutine 交互。
  • 它不替代 -race:synctest 管时间与阻塞关系,数据竞争仍要靠 go test -race。

一句话判据:可在 bubble 内隔离外部事件的时间相关测试,适合使用 synctest;涉及真实网络或进程的测试仍需显式同步与超时。

synctest 是 Go 官方第一次给并发测试提供「时间可控」的官方工具,它的价值不在于让测试变快(那只是副产品),而在于让测试确定——不再依赖真实等待时长来猜测状态。它并不保证任意并发程序只有一种调度顺序,也不会自动消除所有 flaky 测试。

下一节看一个和并发无关但同样属于「值语义」的主题:unique 包如何把重复值规范化成同一个指针。

阅读导航:上一节:2.3 crypto/hkdf、pbkdf2、sha3 · 下一节:3.2 unique 包与值规范化 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练