Go 博客

测试时间(及其他异步性)

Damien Neil
2025年8月26日

在 Go 1.24 中,我们引入了 testing/synctest 包作为实验性包。该包可以显著简化并发、异步代码的测试编写。在 Go 1.25 中,testing/synctest 包已从实验阶段毕业,正式可用。

接下来是我在柏林 GopherCon Europe 2025 上关于 testing/synctest 包的演讲的博客版本。

什么是异步函数?

同步函数非常简单。你调用它,它做一些事情,然后返回。

异步函数则不同。你调用它,它返回,然后它做一些事情。

作为一个具体(尽管有些人工)的例子,以下 Cleanup 函数是同步的。你调用它,它删除一个缓存目录,然后返回。

func (c *Cache) Cleanup() {
    os.RemoveAll(c.cacheDir)
}

CleanupInBackground 是一个异步函数。你调用它,它返回,然后缓存目录被删除……迟早会删除。

func (c *Cache) CleanupInBackground() {
    go os.RemoveAll(c.cacheDir)
}

有时异步函数会在将来做一些事情。例如,context 包的 WithDeadline 函数返回一个上下文,该上下文将在将来被取消。

package context

// WithDeadline returns a derived context
// with a deadline no later than d.
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

当我谈论测试并发代码时,我指的是测试这些类型的异步操作,包括使用实际时间的操作和不使用实际时间的操作。

测试

测试验证系统是否按预期运行。有很多术语描述测试类型——单元测试、集成测试等等——但就我们的目的而言,每种测试都归结为三个步骤

  1. 设置一些初始条件。
  2. 告诉被测系统做一些事情。
  3. 验证结果。

测试同步函数很简单

  • 你调用函数;
  • 函数做一些事情并返回;
  • 你验证结果。

然而,测试异步函数很棘手

  • 你调用函数;
  • 它返回;
  • 你等待它完成它所做的一切;
  • 你验证结果。

如果你没有等待正确的时间量,你可能会发现自己正在验证一个尚未发生或只部分发生的操作的结果。这从来没有好结果。

当你想要断言某事 没有 发生时,测试异步函数尤其棘手。你可以验证该事尚未发生,但你如何确定它以后不会发生呢?

一个例子

为了让事情更具体一些,我们来看一个真实的例子。再次考虑 context 包的 WithDeadline 函数。

package context

// WithDeadline returns a derived context
// with a deadline no later than d.
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

对于 WithDeadline,有两个明显的测试可以编写。

  1. 上下文在截止日期 之前没有 被取消。
  2. 上下文在截止日期 之后被 取消。

让我们编写一个测试。

为了使代码量稍微不那么让人不知所措,我们只测试第二种情况:截止日期过期后,上下文被取消。

func TestWithDeadlineAfterDeadline(t *testing.T) {
    deadline := time.Now().Add(1 * time.Second)
    ctx, _ := context.WithDeadline(t.Context(), deadline)

    time.Sleep(time.Until(deadline))

    if err := ctx.Err(); err != context.DeadlineExceeded {
        t.Fatalf("context not canceled after deadline")
    }
}

这个测试很简单

  1. 使用 context.WithDeadline 创建一个截止日期为未来一秒的上下文。
  2. 等待直到截止日期。
  3. 验证上下文是否被取消。

不幸的是,这个测试显然有问题。它会休眠直到截止日期过期那一刻。我们检查时,上下文很可能尚未被取消。最好的情况是,这个测试会非常不稳定。

让我们修复它。

time.Sleep(time.Until(deadline) + 100*time.Millisecond)

我们可以休眠直到截止日期后100毫秒。一百毫秒在计算机术语中是永恒。这应该没问题。

不幸的是,我们仍然有两个问题。

首先,这个测试需要1.1秒才能执行。这很慢。这是一个简单的测试。它最多应该在毫秒内执行。

其次,这个测试不稳定。一百毫秒在计算机术语中是永恒,但在过载的持续集成(CI)系统中,看到比这长得多的暂停并不罕见。这个测试可能在开发人员的工作站上始终通过,但我预计在CI系统中偶尔会出现失败。

慢或不稳定:选两个

使用真实时间的测试总是慢或不稳定的。通常两者兼而有之。如果测试等待时间超过必要,它就会慢。如果等待时间不够长,它就会不稳定。你可以让测试更慢、更稳定,或者更快、更不稳定,但你不能让它又快又可靠。

我们在 net/http 包中有很多使用这种方法的测试。它们都又慢又/或不稳定,这就是我走上今天这条路的原因。

编写同步函数?

测试异步函数最简单的方法就是不测试。同步函数很容易测试。如果你能将异步函数转换为同步函数,它会更容易测试。

例如,如果我们考虑之前缓存清理函数,同步的 Cleanup 显然优于异步的 CleanupInBackground。同步函数更容易测试,并且调用者可以根据需要轻松启动一个新的 goroutine 在后台运行它。一般来说,你把并发性推到调用堆栈的层次越高越好。

// CleanupInBackground is hard to test.
cache.CleanupInBackground()

// Cleanup is easy to test,
// and easy to run in the background when needed.
go cache.Cleanup()

不幸的是,这种转换并非总是可能的。例如,context.WithDeadline 本质上是一个异步 API。

为可测试性进行代码检测?

一个更好的方法是让我们的代码更具可测试性。

以下是我们的 WithDeadline 测试可能的样子的一个例子

func TestWithDeadlineAfterDeadline(t *testing.T) {
    clock := fakeClock()
    timeout := 1 * time.Second
    deadline := clock.Now().Add(timeout)

    ctx, _ := context.WithDeadlineClock(
        t.Context(), deadline, clock)

    clock.Advance(timeout)
    context.WaitUntilIdle(ctx)
    if err := ctx.Err(); err != context.DeadlineExceeded {
        t.Fatalf("context not canceled after deadline")
    }
}

我们不使用真实时间,而是使用一个模拟时间实现。使用模拟时间可以避免不必要的慢速测试,因为我们从不空等。它还有助于避免测试不稳定性,因为当前时间只在测试调整时才会改变。

市面上有各种模拟时间包,或者你可以自己编写一个。

要使用模拟时间,我们需要修改我们的 API 以接受一个模拟时钟。我在这里添加了一个 context.WithDeadlineClock 函数,它接受一个额外的时钟参数

ctx, _ := context.WithDeadlineClock(
    t.Context(), deadline, clock)

当我们推进模拟时钟时,我们遇到了一个问题。推进时间是一个异步操作。休眠的 goroutine 可能会醒来,计时器可能会在其通道上发送,并且计时器函数可能会运行。我们需要等待这些工作完成,然后才能测试系统的预期行为。

我在这里添加了一个 context.WaitUntilIdle 函数,它会等待与上下文相关的任何后台工作完成

clock.Advance(timeout)
context.WaitUntilIdle(ctx)

这是一个简单的例子,但它演示了编写可测试并发代码的两个基本原则

  1. 使用模拟时间(如果你使用时间)。
  2. 有某种方法可以等待静止,这是一种花哨的说法,意思是“所有后台活动都已停止,系统稳定”。

当然,有趣的问题是我们如何做到这一点。我在这个例子中忽略了细节,因为这种方法有一些很大的缺点。

这很难。使用模拟时钟并不困难,但识别后台并发工作何时完成以及何时可以安全地检查系统状态却很困难。

你的代码变得不那么地道了。你不能使用标准的 `time` 包函数。你需要非常小心地跟踪后台发生的一切。

你不仅需要检测你的代码,还需要检测你使用的任何其他包。如果你调用任何第三方并发代码,你可能就束手无策了。

最糟糕的是,将这种方法改造到现有代码库中几乎是不可能的。

我曾试图将这种方法应用于 Go 的 HTTP 实现,虽然在某些地方取得了一些成功,但 HTTP/2 服务器完全击败了我。特别是,在不进行大量重写的情况下添加检测来检测静止是不可行的,或者至少超出了我的能力范围。

可怕的运行时 hack?

如果我们无法使代码可测试,该怎么办?

如果我们不是检测我们的代码,而是有一种方法来观察未检测系统的行为呢?

一个 Go 程序由一组 goroutine 组成。这些 goroutine 有状态。我们只需要等待所有 goroutine 都停止运行。

不幸的是,Go 运行时不提供任何方法来判断这些 goroutine 正在做什么。或者它提供了吗?

runtime 包包含一个函数,该函数为每个正在运行的 goroutine 提供堆栈跟踪及其状态。这是供人类阅读的文本,但我们可以解析该输出。我们可以用它来检测静止吗?

当然,这是一个糟糕的主意。这些堆栈跟踪的格式不保证会随着时间保持稳定。你不应该这样做。

我做了。而且它奏效了。事实上,它的效果出奇地好。

通过一个简单的模拟时钟实现,少量检测来跟踪哪些 goroutine 是测试的一部分,以及对 runtime.Stack 的一些可怕滥用,我终于有了一种为 http 包编写快速可靠测试的方法。

这些测试的底层实现很糟糕,但它证明了这里有一个有用的概念。

更好的方法

Go 可能内置了并发功能,但测试使用并发的程序很难。

我们面临一个不幸的选择:我们可以编写简单、地道的代码,但它无法快速可靠地测试;或者我们可以编写可测试的代码,但它会复杂且不地道。

所以我们问自己能做些什么来改善这一点。

正如我们之前看到的,编写可测试并发代码所需的两个基本功能是模拟时间和等待静止的方法。

我们需要一种更好的方法来等待静止。我们应该能够询问运行时后台 goroutine 何时完成了它们的工作。我们还希望能够将此查询的范围限制在单个测试中,以便不相关的测试不会相互干扰。

我们还需要更好地支持使用模拟时间进行程序测试。

实现模拟时间并不难,但使用这种实现的Grok代码并不地道。

地道的代码会使用 `time.Timer`,但无法创建模拟的 `Timer`。我们曾问自己,我们是否应该提供一种方式,让测试能够创建模拟的 `Timer`,由测试控制计时器何时触发。

时间的测试实现需要定义一个全新的 time 包版本,并将其传递给所有对时间进行操作的函数。我们曾考虑是否应该定义一个通用的时间接口,就像 net.Conn 是描述网络连接的通用接口一样。

然而,我们意识到,与网络连接不同,模拟时间只有一种可能的实现。一个模拟网络可能需要引入延迟或错误。相比之下,时间只做一件事:向前移动。测试需要控制时间流逝的速度,但一个计划在未来十秒触发的计时器应该总是在未来十(可能是模拟的)秒触发。

此外,我们不想扰乱整个 Go 生态系统。目前大多数程序都使用 time 包中的函数。我们希望这些程序不仅能正常工作,而且能保持地道。

这导致我们得出结论,我们需要一种方法,让测试告诉 `time` 包使用一个模拟时钟,就像 Go playground 使用模拟时钟一样。与 playground 不同的是,我们需要将这种改变的范围限制在一个测试中。(Go playground 使用模拟时钟可能不明显,因为我们将所有模拟延迟都转换为前端的真实延迟,但它确实是。)

synctest 实验

因此,在 Go 1.24 中,我们引入了 testing/synctest,一个用于简化并发程序测试的全新实验性包。在 Go 1.24 发布后的几个月里,我们收集了早期采用者的反馈意见。(感谢所有尝试过的人!)我们进行了多项更改,以解决问题和不足。现在,在 Go 1.25 中,我们已经将 testing/synctest 包作为标准库的一部分发布。

它允许您在所谓的“气泡”中运行函数。在气泡中,时间包使用模拟时钟,而 synctest 包提供一个函数来等待气泡静止。

synctest

synctest 包只包含两个函数。

package synctest

// Test executes f in a new bubble.
// Goroutines in the bubble use a fake clock.
func Test(t *testing.T, f func(*testing.T))

// Wait waits for background activity in the bubble to complete.
func Wait()

Test 在新的气泡中执行函数。

Wait 阻塞,直到气泡中的每个 goroutine 都被阻塞,等待气泡中的另一个 goroutine。我们称这种状态为“持久阻塞”。

使用 synctest 进行测试

让我们看一个 synctest 实际运行的例子。

func TestWithDeadlineAfterDeadline(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        deadline := time.Now().Add(1 * time.Second)
        ctx, _ := context.WithDeadline(t.Context(), deadline)

        time.Sleep(time.Until(deadline))
        synctest.Wait()
        if err := ctx.Err(); err != context.DeadlineExceeded {
            t.Fatalf("context not canceled after deadline")
        }
    })
}

这可能看起来有点熟悉。这是我们之前看过的 context.WithDeadline 的朴素测试。唯一的更改是我们已经将测试包装在 synctest.Test 调用中,以在气泡中执行它,并且我们添加了一个 synctest.Wait 调用。

这个测试快速可靠。它几乎瞬间执行。它精确地测试了被测系统的预期行为。它也不需要修改 context 包。

使用 synctest 包,我们可以编写简单、地道的代码并可靠地测试它。

这当然是一个非常简单的例子,但这是对真实生产代码的真实测试。如果 context 包编写时存在 synctest,我们将更容易编写其测试。

时间

气泡中的时间行为与 Go playground 中的模拟时间非常相似。时间从 UTC 2000 年 1 月 1 日午夜开始。如果由于某种原因需要在一个特定时间点运行测试,你可以等到那时再休眠。

func TestAtSpecificTime(t *testing.T) {
   synctest.Test(t, func(t *testing.T) {
       // 2000-01-01 00:00:00 +0000 UTC
       t.Log(time.Now().In(time.UTC))

       // This does not take 25 years.
       time.Sleep(time.Until(
           time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)))

       // 2025-01-01 00:00:00 +0000 UTC
       t.Log(time.Now().In(time.UTC))
   })
}

只有当气泡中的所有 goroutine 都被阻塞时,时间才会流逝。你可以把气泡想象成模拟一台无限快的计算机:任何数量的计算都不需要时间。

无论真实时间过去了多少,以下测试将始终打印自测试开始以来已流逝的模拟时间为零秒。

func TestExpensiveWork(t *testing.T) {
   synctest.Test(t, func(t *testing.T) {
       start := time.Now()
       for range 1e7 {
           // do expensive work
       }
       t.Log(time.Since(start)) // 0s
   })
}

在下一个测试中,time.Sleep 调用将立即返回,而不是等待十个真实秒。测试将始终打印自测试开始以来正好过去了十个模拟秒。

func TestSleep(t *testing.T) {
   synctest.Test(t, func(t *testing.T) {
       start := time.Now()
       time.Sleep(10 * time.Second)
       t.Log(time.Since(start)) // 10s
   })
}

等待静止

synctest.Wait 函数允许我们等待后台活动完成。

func TestWait(t *testing.T) {
   synctest.Test(t, func(t *testing.T) {
       done := false
       go func() {
           done = true
       }()

       // Wait for the above goroutine to finish.
       synctest.Wait()

       t.Log(done) // true
   })
}

如果在上面的测试中没有 Wait 调用,我们将面临竞态条件:一个 goroutine 修改 done 变量,而另一个 goroutine 在没有同步的情况下读取它。Wait 调用提供了这种同步。

你可能熟悉 -race 测试标志,它启用数据竞态检测器。竞态检测器会感知 Wait 提供的同步,并且不会抱怨这个测试。如果我们忘记 Wait 调用,竞态检测器会正确地抱怨。

synctest.Wait 函数提供同步,但时间的流逝不提供。

在下一个例子中,一个 goroutine 写入 done 变量,而另一个 goroutine 休眠一纳秒后读取它。很明显,当在 synctest 气泡外部使用真实时钟运行时,这段代码包含竞态条件。在 synctest 气泡内部,尽管模拟时钟确保 goroutine 在 time.Sleep 返回之前完成,但竞态检测器仍会报告数据竞态,就像这段代码在 synctest 气泡外部运行时一样。

func TestTimeDataRace(t *testing.T) {
   synctest.Test(t, func(t *testing.T) {
       done := false
       go func() {
           done = true // write
       }()

       time.Sleep(1 * time.Nanosecond)

       t.Log(done)     // read (unsynchronized)
   })
}

添加 Wait 调用提供显式同步并修复数据竞态

time.Sleep(1 * time.Nanosecond)
synctest.Wait() // synchronize
t.Log(done)     // read

示例:io.Copy

利用 synctest.Wait 提供的同步,我们可以编写更简单的测试,减少显式同步。

例如,考虑这个 io.Copy 的测试。

func TestIOCopy(t *testing.T) {
   synctest.Test(t, func(t *testing.T) {
       srcReader, srcWriter := io.Pipe()
       defer srcWriter.Close()

       var dst bytes.Buffer
       go io.Copy(&dst, srcReader)

       data := "1234"
       srcWriter.Write([]byte("1234"))
       synctest.Wait()

       if got, want := dst.String(), data; got != want {
           t.Errorf("Copy wrote %q, want %q", got, want)
       }
   })
}

io.Copy 函数将数据从 io.Reader 复制到 io.Writer。您可能不会立即将 io.Copy 视为并发函数,因为它会阻塞直到复制完成。然而,向 io.Copy 的读取器提供数据是一个异步操作

  • Copy 调用读取器的 Read 方法;
  • Read 返回一些数据;
  • 数据在稍后写入写入器。

在这个测试中,我们正在验证 io.Copy 是否在不等待填充其缓冲区的情况下将新数据写入写入器。

我们一步步地看这个测试,首先我们创建一个 io.Pipe 作为 io.Copy 读取的源

srcReader, srcWriter := io.Pipe()
defer srcWriter.Close()

我们在一个新的 goroutine 中调用 io.Copy,从管道的读取端复制到 bytes.Buffer

var dst bytes.Buffer
go io.Copy(&dst, srcReader)

我们写入管道的另一端,并等待 io.Copy 处理数据

data := "1234"
srcWriter.Write([]byte("1234"))
synctest.Wait()

最后,我们验证目标缓冲区是否包含所需数据

if got, want := dst.String(), data; got != want {
    t.Errorf("Copy wrote %q, want %q", got, want)
}

我们不需要在目标缓冲区周围添加互斥锁或其他同步,因为 synctest.Wait 确保它永远不会被并发访问。

这个测试演示了一些重要的点。

即使是像 io.Copy 这样的同步函数,在返回后不执行额外的后台工作,也可能表现出异步行为。

使用 synctest.Wait,我们可以测试这些行为。

另请注意,此测试不使用时间。许多异步系统涉及时间,但并非全部。

气泡退出

synctest.Test 函数会等待气泡中的所有 goroutine 退出后才返回。当根 goroutine(由 Test 启动的 goroutine)返回后,时间停止前进。

在下一个示例中,Test 会等待后台 goroutine 运行并退出,然后才返回

func TestWaitForGoroutine(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        go func() {
            // This runs before synctest.Test returns.
        }()
    })
}

在这个例子中,我们为一个未来的时间安排了一个 time.AfterFunc。气泡的根 goroutine 在该时间到达之前返回,因此 AfterFunc 从不运行

func TestDoNotWaitForTimer(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        time.AfterFunc(1 * time.Nanosecond, func() {
            // This never runs.
        })
    })
}

在下一个示例中,我们启动一个休眠的 goroutine。根 goroutine 返回,时间停止前进。气泡现在处于死锁状态,因为 Test 正在等待气泡中的所有 goroutine 完成,而休眠的 goroutine 正在等待时间前进。

func TestDeadlock(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        go func() {
            // This sleep never returns and the test deadlocks.
            time.Sleep(1 * time.Nanosecond)
        }()
    })
}

死锁

当一个气泡因气泡中的每个 goroutine 都被其气泡中的另一个 goroutine 持久阻塞而死锁时,synctest 包会发生 panic。

--- FAIL: Test (0.00s)
--- FAIL: TestDeadlock (0.00s)
panic: deadlock: main bubble goroutine has exited but blocked goroutines remain [recovered, repanicked]

goroutine 7 [running]:
(stacks elided for clarity)

goroutine 10 [sleep (durable), synctest bubble 1]:
time.Sleep(0x1)
    /Users/dneil/src/go/src/runtime/time.go:361 +0x130
_.TestDeadlock.func1.1()
    /tmp/s/main_test.go:13 +0x20
created by _.TestDeadlock.func1 in goroutine 9
    /tmp/s/main_test.go:11 +0x24
FAIL    _   0.173s
FAIL

运行时将打印死锁气泡中每个 goroutine 的堆栈跟踪。

在打印气泡中 goroutine 的状态时,运行时会指示 goroutine 何时处于持久阻塞状态。您可以看到此测试中休眠的 goroutine 处于持久阻塞状态。

持久阻塞

“持久阻塞”是 synctest 中的核心概念。

当一个 goroutine 不仅被阻塞,而且只能被同一气泡中的另一个 goroutine 解除阻塞时,它就是持久阻塞的。

当气泡中的每个 goroutine 都被持久阻塞时

  1. synctest.Wait 返回。
  2. 如果没有正在进行的 synctest.Wait 调用,模拟时间将立即前进到下一个将唤醒 goroutine 的点。
  3. 如果没有任何 goroutine 可以通过时间前进唤醒,则气泡会死锁,测试失败。

区分仅仅被阻塞的 goroutine 和 持久 阻塞的 goroutine 对我们来说很重要。我们不想在 goroutine 暂时被其气泡之外发生的某个事件阻塞时宣布死锁。

让我们看看 goroutine 可以非持久阻塞的一些方式。

非持久阻塞:I/O(文件、管道、网络连接等)

最重要的限制是 I/O 不是持久阻塞的,包括网络 I/O。一个从网络连接读取的 goroutine 可能会被阻塞,但它会通过该连接上到达的数据解除阻塞。

对于到某个网络服务的连接来说,这显然是正确的,但对于环回连接也是如此,即使读写器都在同一个气泡中。

当我们向网络套接字(甚至是回环套接字)写入数据时,数据会被传递给内核进行传输。从写入系统调用返回到内核通知连接的另一端数据可用之间有一段时间。Go 运行时无法区分阻塞等待内核缓冲区中已有的数据的 goroutine 和阻塞等待不会到达的数据的 goroutine。

这意味着使用 synctest 测试联网程序通常不能使用真实的网络连接。相反,它们应该使用内存中的模拟。

我这里不再赘述创建模拟网络的过程,但 synctest 包文档包含一个通过模拟网络通信的 HTTP 客户端和服务器测试的完整示例

非持久阻塞:系统调用、cgo 调用、非 Go 的任何东西

系统调用和 cgo 调用不是持久阻塞的。我们只能推断执行 Go 代码的 goroutine 的状态。

非持久阻塞:互斥锁

也许令人惊讶的是,互斥锁不是持久阻塞的。这是一个出于实用性考虑的决定:互斥锁通常用于保护全局状态,因此气泡中的 goroutine 通常需要获取气泡外持有的互斥锁。互斥锁对性能高度敏感,因此为其添加额外的检测可能会减慢非测试程序的运行速度。

我们可以使用 synctest 测试使用互斥锁的程序,但当 goroutine 在获取互斥锁时被阻塞时,模拟时钟不会前进。在我们遇到的任何情况下,这都没有造成问题,但这是一件需要注意的事情。

持久阻塞:time.Sleep

那么什么是持久阻塞?

time.Sleep 显然是持久的,因为只有当气泡中的每个 goroutine 都被持久阻塞时,时间才能前进。

持久阻塞:在同一气泡中创建的通道上的发送或接收

在同一气泡内创建的通道上的通道操作是持久的。

我们区分了气泡内通道(在气泡中创建)和非气泡通道(在任何气泡之外创建)。这意味着,例如,使用全局通道进行同步的函数(例如,控制对全局缓存资源的访问)可以安全地从气泡内调用。

尝试在气泡外部对气泡通道进行操作是错误的。

持久阻塞:属于同一气泡的 sync.WaitGroup

我们还将 sync.WaitGroup 与气泡关联起来。

WaitGroup 没有构造函数,因此我们在第一次调用 GoAdd 时隐式地与气泡关联。

与通道一样,等待属于同一气泡的 WaitGroup 是持久阻塞的,而等待气泡外部的 WaitGroup 则不是。在属于不同气泡的 WaitGroup 上调用 GoAdd 是一个错误。

持久阻塞:sync.Cond.Wait

等待 sync.Cond 总是持久阻塞的。唤醒在不同气泡中等待 Cond 的 goroutine 是一个错误。

持久阻塞:select{}

最后,一个空的 select 是持久阻塞的。(如果其中所有操作都是持久阻塞的,那么带 case 的 select 也是持久阻塞的。)

这就是持久阻塞操作的完整列表。它不是很长,但足以处理几乎所有实际程序。

规则是,当一个 goroutine 被阻塞时,如果我们可以保证它只能被其气泡中的另一个 goroutine 解除阻塞,那么它就是持久阻塞的。

在可能尝试从气泡外部唤醒气泡内 goroutine 的情况下,我们会 panic。例如,从气泡外部对气泡内通道进行操作是错误的。

从 1.24 到 1.25 的变化

我们在 Go 1.24 中发布了 synctest 包的实验版本。为了确保早期采用者了解该包的实验状态,您需要设置一个 GOEXPERIMENT 标志才能使该包可见。

我们从这些早期采用者那里收到的反馈非常宝贵,既证明了该包的实用性,又揭示了 API 需要改进的领域。

这些是实验版本和 Go 1.25 发布版本之间所做的一些更改。

用 Test 替换 Run

API 的原始版本使用 Run 函数创建了一个气泡

// Run executes f in a new bubble.
func Run(f func())

很明显,我们需要一种方法来创建一个范围限定在气泡内的 *testing.T。例如,t.Cleanup 应该在注册它们的同一气泡中运行清理函数,而不是在气泡退出后运行。我们将 Run 重命名为 Test,并使其创建一个范围限定在新气泡生命周期的 T

当气泡的根 goroutine 返回时,时间停止

我们最初只要气泡中包含任何等待未来事件的 goroutine,就会继续在气泡内推进时间。当一个长期存在的 goroutine 从不返回时,例如一个永远从 time.Ticker 读取的 goroutine,这被证明非常令人困惑。我们现在在气泡的根 goroutine 返回时停止推进时间。如果气泡被阻塞等待时间前进,这会导致死锁和 panic,可以进行分析。

移除了“持久”不持久的情况

我们清理了“持久阻塞”的定义。最初的实现存在持久阻塞的 goroutine 可以从气泡外部解除阻塞的情况。例如,通道记录了它们是否在气泡中创建,但没有记录它们在哪一个气泡中创建,因此一个气泡可以解除不同气泡中的通道的阻塞。当前的实现不包含我们知道的任何持久阻塞的 goroutine 可以从其气泡外部解除阻塞的情况。

更好的堆栈跟踪

我们改进了堆栈跟踪中打印的信息。当气泡死锁时,我们现在默认只打印该气泡中 goroutine 的堆栈。堆栈跟踪还清楚地表明气泡中哪些 goroutine 被持久阻塞。

同时发生的随机事件

我们改进了同时发生的事件的随机性。最初,计划在同一时刻触发的计时器总是按照创建顺序触发。现在,这个顺序被随机化了。

未来工作

目前我们对 synctest 包非常满意。

除了不可避免的错误修复,我们目前不期望将来对其进行任何重大更改。当然,随着更广泛的采用,我们总有可能发现需要做的事情。

一个可能的工作领域是改进持久阻塞 goroutine 的检测。如果我们可以使互斥操作持久阻塞,并限制在气泡中获取的互斥锁必须在同一气泡中释放,那将是很好的。

使用 synctest 测试网络代码需要一个模拟网络。net.Pipe 函数可以创建一个模拟的 net.Conn,但目前没有标准库函数可以创建模拟的 net.Listenernet.PacketConn。此外,net.Pipe 返回的 net.Conn 是同步的——每次写入都会阻塞,直到读取消耗数据——这不代表真实的网络行为。也许我们应该在标准库中添加一个好的常用网络接口的模拟实现。

结论

这就是 synctest 包。

我不能说它使并发代码的测试变得简单,因为并发从来都不简单。它所做的只是让您能够使用地道的 Go 和标准的 time 包编写最简单的并发代码,然后为它编写快速可靠的测试。

希望你觉得它有用。

上一篇文章:容器感知的 GOMAXPROCS
博客索引