bigywater.tech
Go · 107

testing/synctest:用假时钟测试并发代码

测试超时、重试、定时器这类依赖时间的并发代码,以前只能真的睡;或者把时间抽象成接口再注入假时钟。testing/synctest 让 time 包在一个“气泡”里用假时钟,所有 goroutine 都阻塞时时间才快进。这篇用一个 TTL 一小时的缓存和一个轮询函数实测它,并验证死锁检测和泄漏检测。

0.00 vs 0.35 秒
测 1 小时 TTL 的缓存用 synctest 与测 300 ms TTL 的缓存用真实时钟的耗时
10 次
每秒轮询、超时 10.001 秒的函数在假时钟下的确定调用次数
2000-01-01
synctest 气泡里时钟的起点(UTC 午夜)

时间被快进

实测 · 本机执行输出
  TestRealTTL:通过,测试自己报告耗时 0.35 秒
  TestFakeTTL:通过,测试自己报告耗时 0.00 秒;c_test.go:28: 起点 2000-01-01 08:00:00 CST;测试里过去了 1h0m1s(假时钟)
  (TestRealTTL 用真实时钟,TTL 只能设 300 ms;TestFakeTTL 的 TTL 是 1 小时,在 synctest 的假时钟里瞬间完成)

气泡里的 time.Sleep(59*time.Minute + 59*time.Second) 瞬间完成,time.Since(start) 报告 1h0m1s,测试本身耗时 0.00 秒。真实时钟版本为了不让测试太慢,只能把 TTL 缩到 300 ms 去测——测的就不是生产参数了。

次数是确定的

实测 · 本机执行输出
  c_test.go:37: 每秒轮询一次、超时 10.001 秒:调用了 10 次 | --- PASS: TestFakePoll (0.00s)
  同一个测试连续重复 50 次(-count=50):通过 50 次,失败 0 次

假时钟只在气泡里所有 goroutine 都阻塞时才推进,所以每秒一次、10.001 秒超时的轮询,调用次数永远是 10,没有调度抖动的影响。真实时钟下的同类测试通常要放宽断言(本文没有统计真实时钟下的偏差)。

Wait

实测 · 本机执行输出
  === RUN   TestFakeWait | c_test.go:49: synctest.Wait() 返回之后,后台 goroutine 已经执行完:done=true | --- PASS: TestFakeWait (0.00s)

synctest.Wait() 等其它 goroutine 都“持久阻塞”或结束再返回,所以不需要靠 time.Sleep 去猜后台 goroutine 跑完没有。

死锁与泄漏检测

实测 · 本机执行输出
  退出码 1;--- FAIL: TestDeadlock (0.00s) | panic: deadlock: all goroutines in bubble are blocked [recovered, repanicked] | panic({0x10069d3a0?, 0x79a587928108?}) | <GOROOT>/src/runtime/panic.go:859 +0x120 | lab.TestDeadlock(0x79a587966248?) | lab.TestDeadlock.func1(0x79a587966488?)

气泡里所有 goroutine 都阻塞且没有定时器可推进,synctest.Test 直接 panic deadlock: all goroutines in bubble are blocked;根 goroutine 退出时还有阻塞的 goroutine,panic main bubble goroutine has exited but blocked goroutines remain。副作用是测试必须把自己启动的 goroutine 都收拾干净。

什么算“持久阻塞”

实测 · 本机执行输出
  退出码 1;--- FAIL: TestLeakExit (0.00s) | panic: deadlock: main bubble goroutine has exited but blocked goroutines remain [recovered, repanicked] | goroutine 5 [running]: | testing.tRunner.func1.2({0x10108d3a0, 0x34aa94bb6120}) | <GOROOT>/src/testing/testing.go:2123 +0x1a0 | testing.tRunner.func1() | <GOROOT>/src/testing/testing.go:2126 +0x2c8 | panic({0x10108d3a0?, 0x34aa94bb6120?}) | <GOROOT>/src/runtime

文档的清单是:气泡内创建的 channel 上的阻塞操作、sync.Cond.Wait、WaitGroup.Wait(Add 在气泡内)和 time.Sleep。网络、外部进程、不在气泡内创建的 channel 等不算,所以文档建议测试自包含、避免真实网络。我没有试 sync.Mutex 之类不在清单里的操作的实际表现。

文档

实测 · 本机执行输出
  The following operations durably block a goroutine: - a blocking send or receive on a channel created within the bubble - a blocking select statement where every case is a channel created within the bubble - sync.Cond.Wait - sync.WaitGroup.Wait, when sync.WaitGroup.Add was called within the bubble - time.Sleep Operations not in the above list are not durably blocking. In particular, the following operations may block a goroutine, but are not durably blocking because the goroutine can be unblocked by an event occurring outside its bubble:

文档建议每个测试自包含:不与气泡外的 goroutine 交互、不用网络(用假网络)、不依赖外部进程、不泄漏 goroutine。

分步演示

testing/synctest
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。测试代码是自己写的:带 TTL 的 Cache 和用 Ticker 的 Poll;真实时钟与 synctest 版本各运行一次;死锁与泄漏的测试故意写成会失败的,取其 panic 信息;读过的文档:go doc testing/synctest(Time、Blocking 两节和持久阻塞清单)
    • 没有验证:testing/synctest 引入的 Go 版本(本机文档没有写)、与 net/http 的 httptest.NewTestServer(内存网络)配合的写法、Mutex 等不在清单里的阻塞操作的表现、气泡与 context 的更多交互、对 time.AfterFunc 的支持细节
    京ICP备2026067502号-1京公网安备11010502063487号