英文来源: Go synctest: Solving Flaky Tests
要理解 synctest 解决的问题,我们首先必须认识核心问题:并发测试中的非确定性。
|
|
这个测试启动一个goroutine来修改共享变量。它将 shared 设为1,休眠1微秒,然后设为2。
与此同时,主测试函数等待5微秒后检查 shared 是否达到2。乍一看,这个测试似乎应该总是通过。毕竟,5微秒应该足够goroutine完成执行。
然而,重复运行测试:
|
|
会显示测试有时会失败。你可能会看到如下输出:
|
|
或者
|
|
这是因为测试是不稳定的。有时goroutine在检查运行时还没有完成,甚至还没有开始。结果取决于系统调度器以及运行时选择goroutine的速度。
time.Sleep 的准确性和调度器的行为可能存在很大差异。操作系统差异和系统负载等因素都会影响时序。这使得任何仅基于休眠的同步策略都不可靠。
虽然这个例子使用微秒级延迟进行演示,但现实世界的问题往往涉及毫秒或秒级的延迟,特别是在高负载下。
受这种不稳定性影响的真实系统包括后台清理、重试逻辑、基于时间的缓存驱逐、心跳监控、分布式环境中的领导者选举等。
像这样依赖时序的测试也可能很耗时。想象一下,如果它必须等待5秒而不是仅仅5微秒。
什么是 synctest?
synctest 是Go 1.24引入的新特性。它通过在受控的隔离环境中运行goroutine,实现了并发代码的确定性测试。
考虑这个不使用 synctest 的例子:
|
|
当你运行这个测试:
|
|
你会发现输出永远不会恰好是 5s。相反,它可能看起来像 5.329s、5.394s 或 5.456s。这些变化来自系统调度和时序分辨率的延迟。
使用 synctest,时间完全受控。持续时间变得一致,输出将始终显示 5s。
要使用 synctest,将你的测试逻辑包装在一个函数中,并将其传递给 synctest.Run():
|
|
然后使用 GOEXPERIMENT=synctest 标志运行测试:
|
|
示例输出:
|
|
注意 synctest 内部的 time.Sleep 立即返回。测试实际上不会等待5秒。这使得测试运行得更快,同时仍然保持准确性。
现在我们知道 synctest 操纵时间来产生确定性行为,我们可以用它来修复之前的不稳定测试。只需用 synctest.Run 包装测试主体:
|
|
通过这个改变,测试将每次都通过。但它是如何解决Go运行时调度器没有选择goroutine运行的问题的呢?
原因是时间是受控的。5微秒是模拟的而不是真实的。当代码运行时,时间实际上是冻结的,synctest 管理其进展。换句话说,逻辑不依赖于真实时间,而是依赖于确定性的执行顺序。
等待机制
除了合成时间外,synctest 还提供了一个强大的同步原语:synctest.Wait 函数。
当你调用 synctest.Wait() 时,它会阻塞直到所有其他goroutine(在同一 synctest 组中)要么完成,要么持久阻塞。Wait() 最常见的用法是启动后台goroutine,然后暂停直到它们达到稳定点,再进行断言。
这里是一个例子,其中 Wait() 确保 afterFunc 回调已被调用:
|
|
当我们调用 cancel() 时,传递给 context.AfterFunc 的函数在单独的goroutine中运行。没有协调,我们无法确定该goroutine何时被调度或何时完成。
因为 synctest 跟踪测试气泡中的所有goroutine,它知道它们的确切状态。当 Wait() 返回时,它保证所有其他goroutine要么完成,要么阻塞。这使你能够对程序状态进行可靠和确定性的断言。
synctest 如何工作
synctest 通过创建称为"气泡"的隔离环境来工作。气泡是一组在受控和独立环境中运行的goroutine,与程序的正常执行分离。
当你调用 synctest.Run(f) 时,Go运行时创建一个新的执行气泡。这个气泡有几个独特的特征,使其与常规Go行为不同:
1. 合成时间
每个气泡都有自己的合成时钟。这个合成时间从2000年1月1日UTC午夜开始(纪元946684800000000000):
|
|
在气泡内部,时间不会实时向前移动。相反,Go暂停时间并观察goroutine在做什么。如果任何goroutine仍然活跃(未阻塞),合成时间保持冻结:
|
|
时间只有在气泡中的所有goroutine都被阻塞时才会前进。这意味着它们正在等待操作,如 time.Sleep、通道接收、互斥锁或其他阻塞调用。
在 synctest 气泡中,时间只为触发预定事件而前进。这使测试完全控制执行时序和顺序。
例如,如果一个goroutine休眠5秒,而所有其他goroutine也被阻塞,Go将立即将合成时间向前移动5秒。这允许goroutine立即恢复,无需等待真实时间过去。
2. Goroutine协调
当调用 synctest.Run(f) 时,当前goroutine成为气泡的根。这个根goroutine管理合成时间并协调气泡内所有其他goroutine的执行。
传递给 synctest.Run 的函数 f 在新goroutine中启动并成为气泡的一部分。然后根goroutine进入循环来管理时间并控制其他气泡goroutine的调度。

被阻塞的goroutine有两类:外部阻塞和持久阻塞。
持久阻塞意味着goroutine无法继续,直到其他东西触发解除阻塞,而那个"其他东西"在测试环境内受控。例子包括:
time.Sleep()sync.Cond.Wait()sync.WaitGroup.Wait()- 对
nil通道的操作 - 所有case都涉及气泡内通道的
select语句 - 在气泡内创建的通道上的发送和接收
如果goroutine等待气泡外的事件,则它们不是持久阻塞。这些包括:
- 文件或网络I/O等系统调用
- 外部事件处理(如从套接字读取)
- 在气泡外创建的通道上的通道操作
从 synctest 的角度来看,在外部事件上阻塞的goroutine被认为是运行的,因为它们的进展取决于现实世界的状态。
所以如果你有一个永远外部阻塞的goroutine,以及另一个在像 time.Sleep(5 * time.Microsecond) 这样的东西上持久阻塞的goroutine,休眠将永远不会完成。由于外部阻塞阻止系统达到完全阻塞状态,合成时间不会前进,持久阻塞的goroutine将保持暂停。
当没有运行的goroutine且所有活跃的goroutine都持久阻塞时,synctest 继续要么唤醒等待 synctest.Wait() 的goroutine,要么继续执行根goroutine。决策逻辑如下:
|
|
此时根goroutine的作用是找到下一个预定的计时器事件。这可能由 time.Sleep、time.Timer、time.Ticker 或 time.AfterFunc 等函数触发。所有这些都在内部创建计时器。
一旦根找到下一个事件,它将合成时间设置到那个时刻(sg.now = next),然后停驻自己并等待测试调度器恢复现在应该运行的goroutine。
请记住,synctest 主要设计用于测试同步逻辑的时序和正确性,而不是完全模拟现实世界的时序行为。如果使用不当,它可能隐藏在现实条件下会出现的错误。
最后注意,本文是在 synctest 仍处于实验阶段时编写的。一些细节可能随时间而改变,但核心概念预期保持不变。
