↓ 跳过正文

Go 并发测试为什么不该靠 Sleep:理解 synctest

正能量5 分钟阅读

从并发任务的超时与取消出发,解释固定 Sleep 为什么造成偶发失败,以及 Go testing/synctest 的虚拟时间、同步点和外部 I/O 边界。

两条 goroutine 时间轴标注启动、等待、虚拟时间推进和状态检查

测试里先启动协程,再 Sleep 一小会儿,最后检查结果,是一种常见写法。它把正确性寄托在机器足够快上:本地通过,到了繁忙的 CI 节点却偶尔失败;把等待时间调大,测试又越来越慢。

Go 1.24 引入的 testing/synctest 尝试改变这种测试方式。使用前需要先分清版本:Go 1.24 的实验接口,与 Go 1.25 正式接口并不完全相同。

用隔离环境控制协程与时间
#

靠真实时间等待并发事件,很难兼顾速度和稳定性。testing/synctest 为一组协程建立隔离的执行环境,在其中使用虚拟时钟,并提供等待协程稳定阻塞的同步点。

在 Go 1.24 中,需要启用 GOEXPERIMENT=synctest,入口叫 Run;使用 Go 1.25 正式接口时,入口改为 Test,不再需要这一实验开关。复制代码前应先确认工具链版本。

为什么“等了十毫秒”不是完成证据
#

假设有一个后台任务,接收到取消信号后关闭输出通道。测试想确认“取消之后任务会退出”。若先调用取消,再等待十毫秒,实际上只做了一个时间估计,没有与任务建立明确的交接。

机器繁忙时,协程可能还没获得执行机会;机器很快时,十毫秒又大多是浪费。更棘手的是反向断言:取消之前任务不能提前退出。某个瞬间看到通道没关闭,只能说明检查那一刻没有关闭,不能说明所有相关协程都已经运行到应有的位置。

写测试前可以先画出三个事件:任务启动、取消发生、任务退出。需要证明的是它们的先后和条件关系。等待多少毫秒只是实现细节,不能替代这些关系。

先理解同步点,再使用虚拟时间
#

Go 1.25 使用 synctest.Test 创建隔离环境,环境内启动的协程也属于其中。synctest.Wait 会等待其他协程达到持久阻塞状态;真实网络 I/O、互斥锁等待等并不都满足这种条件。虚拟时钟的推进同样受阻塞状态约束。

可以把它理解成测试中的检查点:先让相关计算推进到不会自行继续变化的位置,再进行断言。这不意味着所有并发执行顺序都被固定,也不意味着运行一次就穷尽了所有竞争路径。

如果代码不断忙循环,系统没有到达可推进虚拟时间的条件,测试也不会因为用了这个包就自动变快。因此,先把工作协程写成能等待、能取消、能退出的结构,仍然是基础。

验证取消后的退出行为
#

以一个等待取消信号的后台任务为例。下面使用 Go 1.25 接口,在取消前后分别检查退出通道;业务测试可以把协程函数替换为实际任务入口。

package canceltest

import (
    "context"
    "testing"
    "testing/synctest"
)

func TestWorkerStopsOnCancel(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        ctx, cancel := context.WithCancel(t.Context())
        defer cancel()
        stopped := make(chan struct{})

        go func() {
            defer close(stopped)
            <-ctx.Done()
        }()

        synctest.Wait()
        select {
        case <-stopped:
            t.Fatal("取消前任务已退出")
        default:
        }

        cancel()
        synctest.Wait()
        select {
        case <-stopped:
        default:
            t.Fatal("取消后任务仍未退出")
        }
    })
}

通道在隔离环境内创建。第一次检查验证任务没有提前结束;第二次检查验证取消确实导致退出。测试没有猜测调度耗时,也没有用一个很大的超时掩盖不稳定。

真实任务往往还有清理动作,例如刷新缓冲区或释放订阅。此时“通道关闭”应对应业务定义的退出完成点;若协程先关闭通道、再异步清理,这个信号就不能证明清理完成。

怎样拆分超时相关的测试
#

以一个会重试的查询任务为例,可以把验收分成三个问题:第一次失败后是否进入等待;等待结束后是否再次查询;收到取消后是否停止等待且不再产生新查询。

这些问题可以分别用可观察的调用次数、输出事件和退出信号表达。不要只检查最终返回了错误,因为“重试了一百次才失败”和“取消后立即退出”可能产生相同错误文本。

关心的行为值得观察什么容易漏掉什么
重试次数每次查询的调用记录最终失败掩盖多余调用
截止时间到期前后状态变化仅检查耗时近似值
取消清理退出信号与资源释放函数返回后仍有后台任务

业务如果允许并发进行中的请求在取消后收尾,应把这一点写进规则,避免测试把合理收尾误判成新增工作。

外部 I/O 应放在哪一层
#

数据库查询、HTTP 请求和文件系统操作,还涉及连接池、内核及远端进程。一个只控制内部协程与时间的测试,不能证明网络断开后的实际行为。

对上面的查询任务,可以在单元测试中注入一个固定返回值的查询接口,验证重试与取消策略;另写集成测试,检查真实客户端怎样传播截止时间、关闭连接和分类错误。这样失败时能知道是业务调度问题,还是实际 I/O 行为与预期不同。

替身接口的价值是控制条件,而不是让所有测试都通过。它应能表达失败、延迟、取消和异常返回,且不能悄悄省略生产环境中至关重要的错误路径。

Go 1.24 与 1.25 的接口边界
#

Go 1.25 中,testing/synctest 已进入正式可用状态。维护旧模块时,应同时检查 go.mod、本地工具链和 CI 工具链:编辑器能识别代码,不代表 CI 使用的版本也提供相同接口。

示例在 Go 1.25.11、macOS arm64 环境执行 go test -race -count=10 ./...,十次均通过。接入实际任务后,还需验证它的业务状态和资源清理。

采用工具时,还要分清“行为约束”和“实现细节”。假如需求只要求取消后不再接收新任务,就不必强迫实现使用某个通道;可以观察公开入口与最终状态。相反,资源释放属于完成条件时,就应检查释放结果,不能只看到主函数返回便结束测试。

竞态检查与行为测试也要分开理解:两个协程通过锁正确同步,依然可能在业务顺序上出错;没有发现数据竞争,不代表任务只执行了一次或取消传播正确。因此需要同时保留业务断言和竞态检查,让它们分别覆盖不同问题。

对于真实工程,更有价值的推进方式是找一处依赖固定等待的测试,写清事件关系,再替换成明确同步和可控时间,而不是全仓库机械删除 Sleep。每次替换都应保持原来的业务断言;如果旧断言本身不清楚,先澄清它要保护的行为。

参考来源
#