测试并发代码时,经常能看到一种熟悉的写法:启动 goroutine,调用 time.Sleep 等一会儿,再检查结果。

问题在于,“等一会儿”没有可靠定义。休眠太短,CI 负载升高时测试会偶发失败;休眠太长,又会拖慢整个测试套件。涉及超时和定时器时,测试甚至可能真的等待几十秒。

Go 1.25 将 testing/synctest 转为稳定的标准库 API。它通过虚拟时间和明确的同步点,让并发测试摆脱对机器调度速度的依赖。

Sleep 为什么不可靠

假设业务代码会在延迟结束后发送通知:

func NotifyAfter(d time.Duration, out chan<- string) {
    go func() {
        time.Sleep(d)
        out <- "done"
    }()
}

常见测试会先休眠 20 毫秒,再判断任务是否完成:

func TestNotifyAfter(t *testing.T) {
    out := make(chan string, 1)
    NotifyAfter(10*time.Millisecond, out)
    time.Sleep(20 * time.Millisecond)
    if got := <-out; got != "done" {
        t.Fatalf("got %q", got)
    }
}

这段测试默认 goroutine 能在 20 毫秒内获得执行机会,但 Go 调度器并不提供这种保证。CPU 争用、垃圾回收和 CI 虚拟机抖动,都可能让它偶发失败。

增加休眠时间只能降低失败概率,不能消除问题,还会让测试越来越慢。

synctest 如何控制时间

synctest.Test 会创建一个隔离的并发测试环境,官方称其为 bubble。回调中启动的 goroutine 属于同一个 bubble,time.Sleeptime.After 和计时器使用虚拟时钟。

当其中的 goroutine 都进入可识别的持久阻塞状态时,虚拟时间会自动前进到下一个计时事件。因此,逻辑上等待一小时,并不需要测试进程真的等待一小时。

前面的测试可以改成这样:

func TestNotifyAfter(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        out := make(chan string, 1)
        NotifyAfter(time.Hour, out)
        time.Sleep(time.Hour)
        if got := <-out; got != "done" {
            t.Fatalf("got %q", got)
        }
    })
}

这里的一小时是虚拟时间,测试通常会在极短时间内结束。业务代码无需引入自定义 Clock,也不用为了测试而缩短生产环境的超时时间。

但虚拟时间只会在 bubble 内没有 goroutine 可以继续运行时推进。忙循环、真实网络请求以及无法识别的阻塞,都可能导致时间无法按预期前进。synctest 适合控制进程内的并发和计时器,不是外部依赖模拟器。

用 Wait 建立检查点

有些测试不关心时间,只想确认 goroutine 已经执行到阻塞位置。这时可以使用 synctest.Wait

func TestAsyncWork(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        done := false
        go func() { done = true }()
        synctest.Wait()
        if !done {
            t.Fatal("work did not finish")
        }
    })
}

Wait 会等待同一 bubble 中仍存在的其他 goroutine 全部进入持久阻塞状态;已经退出的 goroutine 不再是等待对象。它还建立了可被 Go 竞态检测器识别的同步关系,因此上面对 done 的读写不会被报告为数据竞争。

runtime.Gosched 不具备这种语义。它只提示调度器让其他 goroutine 运行,既不保证执行到哪里,也不构成可靠的同步点。

需要注意,Wait 只能在 bubble 内调用,同一个 bubble 中也不能有多个 goroutine 并发调用它。它不是等待任意业务条件成立的万能函数,测试仍应优先使用 channel、WaitGroup 等业务需要的同步机制。

超时测试可以瞬间完成

synctest 很适合测试 context 超时、重试间隔、心跳检测和缓存过期。即使业务代码配置了 30 秒超时,只要相关 goroutine 全部阻塞,虚拟时钟就能直接推进到 deadline,无需等待真实的 30 秒。

这样既能加快测试,也不用偷偷把生产环境的 30s 改成测试专用的 10ms。超时数值和执行路径保持一致,可以减少测试与生产行为的偏差。

版本差异必须留意

稳定版 testing/synctest 要求使用 Go 1.25 或更高版本,go.mod 也应声明相应版本。

Go 1.24 曾提供实验版本,需要开启实验开关,并使用 synctest.Run。Go 1.25 稳定版将入口改为 synctest.Test,网上早期的 Run 示例不能直接复制。

升级后建议同时运行 go test ./...go test -race ./...,分别检查普通测试与数据竞争。

数据库、HTTP 服务或文件变化等外部事件,应使用进程内 fake、stub 或 net.Pipe 隔离。httptest.Server 使用真实的回环网络连接,阻塞在这类网络 I/O 上不属于持久阻塞,可能导致虚拟时钟无法推进。

写在最后

并发测试的核心不是“多等一会儿”,而是建立确定的同步条件。testing/synctest 用虚拟时间消除真实等待,用 Wait 提供明确的 goroutine 检查点,让超时、定时器和异步任务测试更快、更稳定。

如果某个测试“本地从不失败,CI 偶尔变红”,或者为了验证 30 秒超时真的等待 30 秒,就值得检查它是否可以放进 synctest.Test。测试结果应该由同步关系决定,而不是由机器当时有多忙决定。