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.24 | GOEXPERIMENT=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,阻塞关系无法被追踪 |
| 不要依赖外部进程 | 同上,进程的时序不受假时钟控制 |
| 不要留后台 goroutine | bubble 结束时会 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 包与值规范化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。