什么是异步函数?
同步函数非常简单。调用它,它执行一些操作后返回。
异步函数则不同。调用它即返回,然后它继续执行一些操作。
举一个具体的例子,虽然有些不自然,但下面的 Cleanup 函数是同步的。你调用它,它会删除一个缓存目录,然后返回。
|
|
CleanupInBackground 是一个异步函数。你调用它即返回,然后缓存目录就会被删除……这迟早会发生。
|
|
有时异步函数会在将来执行某些操作。例如, context 包的 WithDeadline 函数返回一个将来会被取消的 context。
|
|
当我谈到测试并发代码时,我的意思是测试这些类型的异步操作,包括使用实时的操作和不使用实时的操作。
测试
测试验证系统的行为是否符合我们的预期。描述测试类型的术语有很多,例如单元测试、集成测试等等,但就我们的目的而言,每种测试都简化为三个步骤:
- 设置初始条件
- 告诉被测系统做某事
- 验证结果
测试同步函数很简单
- 调用该函数
- 函数执行并返回
- 验证结果
然后,测试异步函数很棘手:
- 调用该函数
- 它返回
- 等待它完成要做的事情
- 验证结果
如果没有等待正确的时间,可能会发现自己正在验证尚未发生或仅部分发生的操作的结果。这绝对不会有好结果。
当你想断言某件事尚未发生时,测试异步函数尤其棘手。你可以验证这件事尚未发生,但如何确定它稍后不会发生呢?
举一个栗子
为了使事情更具体一些,让我们来看一个真实的例子。再次考虑 context 包的 WithDeadline 函数。
有两个明显的测试需要为 WithDeadline 编写。
- 期限前未取消上下文
- 截止日期过后,该上下文将被取消
让我们写一个测试。
为了使代码量稍微不那么繁琐,我们只测试第二种情况:截止日期过后,上下文被取消。
|
|
这个测试很简单:
- 使用
context.WithDeadline创建一个具有未来一秒截止期限的上下文。 - 等到最后期限
- 验证上下文是否已取消
不幸的是,这个测试显然有问题。它会一直休眠到截止时间到期的那一瞬间。很有可能,当我们检查它的时候,上下文还没有被取消。这个测试充其量也只能算是个很不稳定的测试。
让我们修复它。
|
|
我们可以等到截止时间过后100毫秒再休眠。对计算机而言,这100毫秒已经绰绰有余,完全足够任务完成。
不幸的是,我们仍然面临两个问题。
首先,这个测试执行耗时 1.1 秒。这太慢了。这是一个简单的测试,最多应该在几毫秒内完成。
其次,这个测试不够稳定。一百毫秒在计算机范畴内堪称漫长,但在负载过重的持续集成(CI)系统中,出现远超这个时长的延迟并不罕见。该测试在开发者的工作站上或许能持续通过,但在CI系统中很可能出现间歇性失败。
缓慢或不稳定: 请选择两项
使用实时的测试总是很慢或不稳定。通常两者兼而有之。如果测试等待的时间超过必要时间,它就会很慢。如果等待的时间不够长,它就会不稳定。你可以让测试更慢、更稳定,或者让测试更慢、更不稳定,但你无法让它快速可靠。
net/http 包里有很多测试都用到了这种方法。它们都很慢,而且/或者不稳定,这就是我今天开始研究这个问题的原因。
编写同步函数
测试异步函数最简单的方法就是不去测试它。同步函数很容易测试。如果你能把异步函数转换成同步函数,测试起来会更容易。
例如,如果我们考虑之前的缓存清理函数,同步 Cleanup 显然比异步 CleanupInBackground 更好。同步函数更容易测试,并且调用者可以根据需要轻松启动一个新的 Goroutine 在后台运行它。通常而言,将并发处理推向调用栈的越高层越好。
|
|
不幸的是,这种转换并不总是可行的。例如, context.WithDeadline 本质上是一个异步 API。
检查代码的可测试性
更好的方法是让我们的代码更易于测试。
以下是我们的 WithDeadline 测试的一个示例:
|
|
我们不使用真实时间,而是使用伪时间实现。使用伪时间可以避免不必要的测试速度变慢,因为我们永远不会无所事事地等待。它还有助于避免测试不稳定,因为当前时间仅在测试调整时才会更改。
有各种虚假的时间包,或者你可以编写自己的时间包。
要使用虚假时间,我们需要修改 API 以接受虚假时钟。我在这里添加了一个 context.WithDeadlineClock 函数,它接受一个额外的时钟参数:
|
|
当我们向前拨动模拟时钟时,会遇到一个问题:时间推进是异步操作。休眠中的协程可能被唤醒、定时器可能向通道发送信号、计时器函数可能被触发——我们需要等待这些操作全部完成,才能开始测试系统的预期行为。
我在这里添加了一个 context.WaitUntilIdle 函数,它等待与上下文相关的任何后台工作完成:
|
|
这是一个简单的例子,但它演示了编写可测试并发代码的两个基本原则:
- 使用假时间(如果您使用时间)。
- 有某种方式来等待静止,这是一种说法“所有后台活动都已停止并且系统稳定”。
当然,有趣的问题是我们如何做到这一点。在这个例子中,我略过了细节,因为这种方法存在一些很大的缺点。
这很难。使用假时钟并不难,但确定后台并发工作何时完成,以及何时可以安全地检查系统状态却很难。
你的代码变得不那么符合惯用语法了。你不能使用标准的 time 包函数。你需要非常小心地跟踪后台发生的所有事情。
你不仅需要检测你的代码,还需要检测你使用的其他任何包。如果你调用了任何第三方并发代码,那么你可能就没那么幸运了。
最糟糕的是,将这种方法改进到现有的代码库中几乎是不可能的。
我尝试将这种方法应用于 Go 的 HTTP 实现,虽然在某些地方取得了一些成功,但 HTTP/2 服务器却让我束手无策。尤其是,如果不进行大量重写,添加检测静止状态的工具就被证明是不可行的,或者至少超出了我的能力范围。
糟糕的运行时 hack
如果我们不能使我们的代码可测试,我们该怎么办?
如果我们不对我们的代码进行检测,而是有办法观察未检测系统的行为,那会怎样?
一个 Go 程序由一组 Goroutine 组成。这些 Goroutine 有状态。我们只需要等待所有 Goroutine 停止运行即可。
不幸的是,Go 运行时没有提供任何方法来判断这些 goroutine 正在做什么。或者有?
runtime 包中包含一个函数,它可以提供每个正在运行的 goroutine 的堆栈跟踪及其状态。这是供人类阅读的文本,但我们可以解析该输出。我们可以用它来检测静止状态吗?
当然,这绝对是个糟糕的主意。这些堆栈跟踪的格式无法保证长期稳定。你不应该这么做。
我做到了。而且成功了。事实上,效果出奇地好。
通过简单实现一个假时钟、少量仪器来跟踪哪些 goroutine 是测试的一部分,以及对 runtime.Stack 的一些可怕的滥用。Stack,我终于找到了一种为 http 包编写快速、可靠的测试的方法。
这些测试的底层实现很糟糕,但它表明这里有一个有用的概念。
使用 synctest 进行测试
上面介绍了背景,简单来说识别到协程阻塞就推进时钟,接下来看看怎么使用。
|
|
我们将测试包装在 synctest.Test 调用中,让它在一个隔离环境里执行,并且额外添加了一个 synctest.Wait 调用。
该测试快速可靠,几乎可以立即运行,能够精确测试被测系统的预期行为,并且无需修改 context 包。
时间
气泡中的时间行为与 Go Playground 中的伪时间非常相似。时间从 UTC 时间 2000 年 1 月 1 日午夜开始。如果您出于某种原因需要在某个特定时间点运行测试,您可以休眠到那时。
|
|
时间只会在隔离环境中的所有协程都进入阻塞状态时才会流逝。您可以将这个隔离环境想象成一台模拟无限速度的计算机:无论进行多少计算,都不会消耗任何时间。
以下测试函数无论实际经过多长时间,都会打印出测试开始后模拟时间的流逝为0秒:
|
|
而在接下来的测试中,time.Sleep 调用会立即返回,而不是等待真实的十秒。该测试将始终打印出自测试开始后,正好有十秒模拟时间已经流逝:
|
|
wait
synctest.Wait 函数让我们等待后台活动完成。
|
|
如果上面的测试中没有 Wait 调用,就会出现竞争条件:一个 Goroutine 修改了 done 变量,而另一个 Goroutine 却在不同步的情况下读取了该变量。 Wait 调用提供了这种同步。
您可能熟悉 -race 测试标志,它启用了数据竞争检测器。竞争检测器知道 Wait 提供的同步机制,因此不会对此测试发出警告。如果我们忘记了 Wait 调用,竞争检测器就会正确地发出警告。
synctest.Wait 函数可提供同步功能,但时间的流逝无法提供同步功能。
在下一个示例中,一个 goroutine 向 done 变量写入数据,而另一个 goroutine 则先休眠一纳秒,再从该变量读取数据。显然,若在 synctest 环境之外使用真实时钟运行这段代码,其中会存在竞态条件。在 synctest 环境中,尽管虚拟时钟会确保该 goroutine 在 time.Sleep 返回前完成操作,但竞态检测器仍会报告数据竞争 —— 就像这段代码在 synctest 环境外运行时的情况一样。
|
|
添加 Wait 调用可提供显式同步并修复数据争用:
|
|
Example: io.Copy¶
利用 synctest.Wait 提供的同步机制,我们能够编写更简洁的测试,减少显式同步操作。
|
|