Go 并发测试不再靠 Sleep:testing/synctest 实践

Ruby
Ruby
发布时间:2026-08-25 | 51人浏览过
原创

前言

并发测试里最常见的“先 `time.Sleep` 一下再断言”,表面省事,实际既拖慢测试,也容易把结果交给调度时机和 CI 负载。Go 1.25 把 `testing/synctest` 纳入稳定标准库后,测试可以用虚拟时间处理超时和定时器,再用 `Wait` 建立明确检查点,哪些场景适合替换、哪些边界要避开,本文会顺着这些问题拆开说明。

给并发测试塞一个 time.Sleep,几乎是很多 Go 项目的默认写法:先启动 goroutine,等一会儿,再去断言结果。问题在于,这个“等一会儿”从来不是确定条件,它只是在赌当前机器、当前调度和当前 CI 负载刚好配合。Go 1.25 稳定提供的 testing/synctest,正好把这类测试改回到可推导、可重复的轨道上:该靠时间推进的交给虚拟时间,该靠同步点判断的就明确同步点。

为什么并发测试里的 Sleep 总是不稳

先看一个很典型的例子:业务代码会在延迟结束后发送通知。

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

很多测试会直接写成下面这样:

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)
    }
}

这段代码的问题,不在于 20 毫秒是不是“通常够用”,而在于它把正确性建立在了一个不存在的保证上:测试默认 goroutine 一定会在这段时间内拿到执行机会。但 Go 调度器并不承诺这一点,CPU 争用、垃圾回收、虚拟机抖动,都会让这种测试在 CI 上偶发变红。

把休眠时间从 20 毫秒改成 50 毫秒、100 毫秒,最多只是降低失败概率,不能真正解决问题。代价也很直接:测试越来越慢,整套测试的反馈时间越来越差。

synctest 怎么用虚拟时间接管等待

Go 1.25 将 testing/synctest 转为稳定 API,它的核心思路是提供一个隔离的并发测试环境。通过 synctest.Test 创建的回调环境,官方称为 bubble。这个 bubble 中启动的 goroutine,共享一套受控的虚拟时钟,像 time.Sleep、time.After 和各类计时器,都不再直接依赖真实世界的时钟推进。

用纸张卡片对比 Sleep 等待与 synctest 虚拟时间的测试差异
从真实等待到虚拟时间把“真实等待”改成“虚拟时间推进”,是 synctest 改写并发测试的关键收益。

当 bubble 里的 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)
        }
    })
}

这里的 time.Hour 是虚拟时间。测试通常会在极短时间内结束,但逻辑路径和生产代码保持一致。这样做有两个直接好处:一是不用引入自定义 Clock;二是不用为了测试把线上超时时间偷偷改成一个不真实的短值。

当然,synctest 也有边界。虚拟时间只会在 bubble 内没有 goroutine 可以继续运行时推进,如果代码里存在忙循环、真实网络请求,或者某些无法被识别的阻塞,时间就可能无法按预期前进。所以它适合的是进程内并发与计时器控制,不是替代所有外部依赖的通用模拟器。

不关心时间时,用 Wait 建立可靠检查点

有些测试并不是要验证“多久之后发生什么”,而只是想确认某个 goroutine 已经运行到了预期位置。这种场景里,synctest.Wait 比 Sleep 更合适。

用便签和箭头说明 synctest.Wait 作为 goroutine 检查点的语义与限制
Wait 的语义不是“等一下”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,不再是它的等待对象。

这一点和 runtime.Gosched 有本质区别。Gosched 只是提示调度器可以切换执行,既不保证其他 goroutine 一定跑到了哪里,也不构成测试里可依赖的同步条件。用它来赌时序,本质上还是另一种不稳定写法。

Wait 还有一个很实际的价值:它会建立能被 Go 竞态检测器识别的同步关系。因此,上面示例里对 done 的写入和读取,不会被报告成数据竞争。这让它在异步状态检查中比手写“睡一下再读变量”更可靠。

不过它也不是万能等待器。Wait 只能在 bubble 内调用,而且同一个 bubble 中不能有多个 goroutine 并发调用它。测试代码仍然应该优先使用 channel、WaitGroup 等业务本身就需要的同步机制;Wait 更像是帮助测试建立确定观察点,而不是代替业务同步设计。

超时、重试和过期逻辑,最适合交给 synctest

只要测试目标涉及时间推进,synctest 的收益通常都很明显。典型场景包括 context 超时、重试间隔、心跳检测、缓存过期等。

比如业务里真实配置的是 30s 超时,传统测试往往会忍不住改成测试专用的 10ms,只为了别让测试太慢。但这样一来,测试跑的其实已经不是生产参数,路径相同,行为细节却未必一致。用 synctest 后,只要相关 goroutine 都阻塞住,虚拟时钟就能直接推进到 deadline,不需要真实等待 30 秒。

这类改写的意义不只是“更快”,还在于它减少了测试和生产之间的偏差。超时值、定时器周期和执行路径保持原样,测试判断的基础也会更扎实。

上手前要确认的版本与边界

使用稳定版 testing/synctest,前提是 Go 版本不低于 1.25,go.mod 也应声明对应版本。

用专栏卡片汇总 Go 1.24 与 1.25 的 synctest 差异及外部依赖边界
迁移前先看版本和边界版本入口、测试命令和外部依赖边界,是实际迁移时最容易忽略的三类问题。

这里有一个很容易踩到的差异:Go 1.24 提供过实验版本,需要开启实验开关,并且入口函数是 synctest.Run;到了 Go 1.25 稳定版,入口改成了 synctest.Test。因此,网上早期基于 Run 的示例,不能直接照抄。

完成升级后,建议同时执行下面两组检查:

go test ./...
go test -race ./...

前者确认普通测试路径,后者专门检查数据竞争。尤其是在把原先依赖 Sleep 的并发测试迁移到 synctest 后,-race 仍然值得保留为常规验证步骤。

还有一点边界不能忽略:数据库、HTTP 服务、文件变化这类外部事件,不应指望 synctest 自动替你处理。更稳妥的做法,是使用进程内 fake、stub 或 net.Pipe 做隔离。像 httptest.Server 这类方案虽然方便,但它使用的是真实回环网络连接,阻塞在这类网络 I/O 上不属于可推进虚拟时间的持久阻塞,测试可能因此卡在“时间不走”。

并发测试该依赖同步条件,而不是机器运气

回头看,Sleep 在并发测试里的最大问题,从来不只是慢,而是它把测试是否通过交给了不确定的调度时机。testing/synctest 提供的价值,则是把判断依据重新拉回到两个清晰维度:需要时间推进时,用虚拟时间;需要确认 goroutine 状态时,用明确同步点。

如果你的某个测试总是“本地稳过,CI 偶尔失败”,或者为了验证一次 30 秒超时真的让测试挂 30 秒,那就值得检查这段逻辑是否适合放进 synctest.Test。对并发代码来说,可靠测试的前提不是多等一会儿,而是让结果由同步关系决定。

相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表