Go 博客

使用 testing/synctest 测试并发代码

Damien Neil
2025 年 2 月 19 日

Go 的标志性特性之一是其内置的并发支持。Goroutines 和 channel 是编写并发程序的简单有效的原语。

然而,测试并发程序可能既困难又容易出错。

在 Go 1.24 中,我们引入了一个新的实验性 testing/synctest 包,以支持测试并发代码。本文将解释此实验的动机,演示如何使用 synctest 包,并讨论其潜在的未来。

在 Go 1.24 中,testing/synctest 包是实验性的,不受 Go 兼容性承诺的约束。默认情况下它不可见。要使用它,请在你的环境中设置 GOEXPERIMENT=synctest 来编译你的代码。

测试并发程序很困难

首先,让我们考虑一个简单的例子。

context.AfterFunc 函数安排在 context 取消后,在一个独立的 goroutine 中调用一个函数。下面是 AfterFunc 的一个可能的测试

func TestAfterFunc(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())

    calledCh := make(chan struct{}) // closed when AfterFunc is called
    context.AfterFunc(ctx, func() {
        close(calledCh)
    })

    // TODO: Assert that the AfterFunc has not been called.

    cancel()

    // TODO: Assert that the AfterFunc has been called.
}

在这个测试中,我们想检查两个条件:在 context 取消之前,函数未被调用;在 context 取消之后,函数调用了。

在并发系统中检查负面条件很困难。我们可以轻松测试函数是否尚未被调用,但我们如何检查它不会被调用呢?

一种常见的方法是等待一段时间,然后得出结论认为某个事件不会发生。让我们尝试在测试中引入一个执行此操作的辅助函数。

// funcCalled reports whether the function was called.
funcCalled := func() bool {
    select {
    case <-calledCh:
        return true
    case <-time.After(10 * time.Millisecond):
        return false
    }
}

if funcCalled() {
    t.Fatalf("AfterFunc function called before context is canceled")
}

cancel()

if !funcCalled() {
    t.Fatalf("AfterFunc function not called after context is canceled")
}

这个测试很慢:10 毫秒时间不长,但在许多测试中会累积起来。

这个测试也容易不稳定 (flaky):10 毫秒在快速计算机上是很长的时间,但在共享和超载的 CI 系统上,看到持续几秒的暂停并非不寻常。

我们可以通过使其变慢来减少不稳定,也可以通过使其更容易不稳定来加快速度,但我们不能使其既快速又可靠。

引入 testing/synctest 包

testing/synctest 包解决了这个问题。它允许我们在不修改被测试代码的情况下,将这个测试重写得简单、快速、可靠。

该包只包含两个函数:RunWait

Run 在一个新的 goroutine 中调用一个函数。这个 goroutine 以及由此启动的任何 goroutine 都存在于一个我们称为 bubble 的隔离环境中。Wait 等待当前 goroutine 的 bubble 中的每个 goroutine 都阻塞在 bubble 中的另一个 goroutine 上。

让我们使用 testing/synctest 包重写上面的测试。

func TestAfterFunc(t *testing.T) {
    synctest.Run(func() {
        ctx, cancel := context.WithCancel(context.Background())

        funcCalled := false
        context.AfterFunc(ctx, func() {
            funcCalled = true
        })

        synctest.Wait()
        if funcCalled {
            t.Fatalf("AfterFunc function called before context is canceled")
        }

        cancel()

        synctest.Wait()
        if !funcCalled {
            t.Fatalf("AfterFunc function not called after context is canceled")
        }
    })
}

这几乎与我们最初的测试相同,但我们将测试包装在 synctest.Run 调用中,并在断言函数是否已被调用之前调用了 synctest.Wait

Wait 函数等待调用者 bubble 中的每个 goroutine 都阻塞。当它返回时,我们知道 context 包要么已经调用了函数,要么在我们在采取进一步行动之前不会调用它。

现在这个测试既快速又可靠了。

测试也更简单了:我们将 calledCh channel 替换为了一个布尔值。以前我们需要使用 channel 来避免测试 goroutine 和 AfterFunc goroutine 之间的数据竞争,但现在 Wait 函数提供了这种同步。

竞态检测器理解 Wait 调用,因此在使用 -race 运行此测试时会通过。如果我们移除第二个 Wait 调用,竞态检测器将正确地报告测试中的数据竞争。

测试时间

并发代码经常处理时间。

测试处理时间的代码可能很困难。在测试中使用真实时间会导致测试缓慢且不稳定,如上所述。使用伪造时间需要避免使用 time 包的函数,并且需要设计被测试的代码以使用可选的伪造时钟。

testing/synctest 包使得测试使用时间的代码更简单。

Run 启动的 bubble 中的 goroutine 使用伪造时钟。在 bubble 中,time 包中的函数操作的是伪造时钟。当所有 goroutine 都阻塞时,bubble 中的时间会向前推进。

为了演示,让我们为 context.WithTimeout 函数编写一个测试。WithTimeout 创建一个 context 的子 context,它在给定的超时时间后过期。

func TestWithTimeout(t *testing.T) {
    synctest.Run(func() {
        const timeout = 5 * time.Second
        ctx, cancel := context.WithTimeout(context.Background(), timeout)
        defer cancel()

        // Wait just less than the timeout.
        time.Sleep(timeout - time.Nanosecond)
        synctest.Wait()
        if err := ctx.Err(); err != nil {
            t.Fatalf("before timeout, ctx.Err() = %v; want nil", err)
        }

        // Wait the rest of the way until the timeout.
        time.Sleep(time.Nanosecond)
        synctest.Wait()
        if err := ctx.Err(); err != context.DeadlineExceeded {
            t.Fatalf("after timeout, ctx.Err() = %v; want DeadlineExceeded", err)
        }
    })
}

我们编写这个测试,就像我们在使用真实时间一样。唯一的区别是我们将测试函数包装在 synctest.Run 中,并在每次调用 time.Sleep 后调用 synctest.Wait 来等待 context 包的计时器完成运行。

阻塞和 bubble

testing/synctest 中的一个关键概念是 bubble 变为持久阻塞(durably blocked)。当 bubble 中的每个 goroutine 都被阻塞,并且只能被 bubble 中的另一个 goroutine 解除阻塞时,就会发生这种情况。

当 bubble 持久阻塞时

  • 如果存在未返回的 Wait 调用,它会返回。
  • 否则,时间将推进到下一个可能解除 goroutine 阻塞的时间点(如果存在)。
  • 否则,bubble 发生死锁,并且 Run 会引发 panic。

如果任何 goroutine 被阻塞,但可能被 bubble 外部的某个事件唤醒,则 bubble 不会持久阻塞。

能够持久阻塞 goroutine 的操作完整列表如下:

  • 对 nil channel 进行发送或接收
  • 阻塞在同一 bubble 中创建的 channel 上的发送或接收
  • select 语句中的每个 case 都是持久阻塞的
  • time.Sleep
  • sync.Cond.Wait
  • sync.WaitGroup.Wait

互斥锁 (Mutex)

sync.Mutex 的操作不是持久阻塞的。

函数获取全局互斥锁是很常见的。例如,reflect 包中的许多函数使用由互斥锁保护的全局缓存。如果在 synctest bubble 中的 goroutine 试图获取由 bubble 外部的 goroutine 持有的互斥锁时被阻塞,它不是持久阻塞的——它确实被阻塞了,但会被来自其 bubble 外部的 goroutine 解除阻塞。

由于互斥锁通常不会被长时间持有,我们简单地将它们排除在 testing/synctest 的考虑范围之外。

Channel

在 bubble 中创建的 channel 与在外部创建的 channel 行为不同。

Channel 操作只有当 channel 是 bubble 化的(在 bubble 中创建的)时才是持久阻塞的。从 bubble 外部操作 bubble 化的 channel 会引发 panic。

这些规则确保 goroutine 仅在与其 bubble 内部的 goroutine 通信时才会持久阻塞。

I/O (输入/输出)

外部 I/O 操作,例如从网络连接读取,不是持久阻塞的。

网络读取可能被 bubble 外部的写入(甚至可能是其他进程的写入)解除阻塞。即使网络连接的唯一写入者也在同一个 bubble 中,运行时也无法区分连接是正在等待更多数据到达,还是内核已经接收到数据并正在处理其传递。

使用 synctest 测试网络服务器或客户端通常需要提供一个伪造的网络实现。例如,net.Pipe 函数创建一对使用内存中网络连接的 net.Conn,可以在 synctest 测试中使用。

Bubble 生命周期

Run 函数在一个新的 bubble 中启动一个 goroutine。当 bubble 中的每个 goroutine 都退出时,它返回。如果 bubble 持久阻塞且无法通过时间推进解除阻塞,它会引发 panic。

Run 返回之前要求 bubble 中的每个 goroutine 都退出,这意味着测试在完成之前必须小心清理所有后台 goroutine。

测试网络代码

让我们看另一个例子,这次使用 testing/synctest 包来测试一个网络程序。在这个例子中,我们将测试 net/http 包对 100 Continue 响应的处理。

发送请求的 HTTP 客户端可以包含一个 “Expect: 100-continue” 头部,以告知服务器客户端有额外的数据要发送。服务器随后可以响应 100 Continue 信息性响应来请求剩余的请求内容,或响应其他状态码告知客户端不需要该内容。例如,上传大文件的客户端可以使用此功能在发送文件之前确认服务器愿意接受该文件。

我们的测试将确认,当发送 “Expect: 100-continue” 头部时,HTTP 客户端在服务器请求之前不会发送请求内容,并在收到 100 Continue 响应后发送内容。

通常,测试通信的客户端和服务器可以使用回环网络连接。然而,在使用 testing/synctest 时,我们通常会希望使用伪造的网络连接,以便能够检测到所有 goroutine 何时阻塞在网络上。我们将通过创建一个使用 net.Pipe 创建的内存中网络连接的 http.Transport(一个 HTTP 客户端)来开始此测试。

func Test(t *testing.T) {
    synctest.Run(func() {
        srvConn, cliConn := net.Pipe()
        defer srvConn.Close()
        defer cliConn.Close()
        tr := &http.Transport{
            DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
                return cliConn, nil
            },
            // Setting a non-zero timeout enables "Expect: 100-continue" handling.
            // Since the following test does not sleep,
            // we will never encounter this timeout,
            // even if the test takes a long time to run on a slow machine.
            ExpectContinueTimeout: 5 * time.Second,
        }

我们使用设置了 “Expect: 100-continue” 头部在此传输上发送一个请求。请求在一个新的 goroutine 中发送,因为它直到测试结束才会完成。

        body := "request body"
        go func() {
            req, _ := http.NewRequest("PUT", "http://test.tld/", strings.NewReader(body))
            req.Header.Set("Expect", "100-continue")
            resp, err := tr.RoundTrip(req)
            if err != nil {
                t.Errorf("RoundTrip: unexpected error %v", err)
            } else {
                resp.Body.Close()
            }
        }()

我们读取客户端发送的请求头部。

        req, err := http.ReadRequest(bufio.NewReader(srvConn))
        if err != nil {
            t.Fatalf("ReadRequest: %v", err)
        }

现在来到测试的核心。我们想断言客户端此时不会发送请求体。

我们启动一个新的 goroutine 将发送到服务器的请求体复制到一个 strings.Builder 中,等待 bubble 中的所有 goroutine 阻塞,然后验证我们尚未从请求体中读取任何内容。

如果我们忘记调用 synctest.Wait,竞态检测器将正确地报告数据竞争,但有了 Wait,这是安全的。

        var gotBody strings.Builder
        go io.Copy(&gotBody, req.Body)
        synctest.Wait()
        if got := gotBody.String(); got != "" {
            t.Fatalf("before sending 100 Continue, unexpectedly read body: %q", got)
        }

我们向客户端写入一个 “100 Continue” 响应,并验证它现在发送了请求体。

        srvConn.Write([]byte("HTTP/1.1 100 Continue\r\n\r\n"))
        synctest.Wait()
        if got := gotBody.String(); got != body {
            t.Fatalf("after sending 100 Continue, read body %q, want %q", got, body)
        }

最后,我们通过发送 “200 OK” 响应来结束请求。

在此测试期间,我们启动了几个 goroutine。synctest.Run 调用将等待它们全部退出后才返回。

        srvConn.Write([]byte("HTTP/1.1 200 OK\r\n\r\n"))
    })
}

这个测试可以轻松扩展以测试其他行为,例如验证如果服务器没有请求则不发送请求体,或者如果服务器在超时时间内没有响应则发送请求体。

实验状态

我们在 Go 1.24 中引入 testing/synctest 作为实验性包。根据反馈和经验,我们可能会带修改或不带修改发布它,继续进行实验,或者在未来的 Go 版本中将其移除。

该包默认不可见。要使用它,请在你的环境中设置 GOEXPERIMENT=synctest 来编译你的代码。

我们希望听到你的反馈!如果你尝试使用 testing/synctest,请在 go.dev/issue/67434 上报告你的经验,无论是积极的还是消极的。

下一篇文章:使用 Swiss Tables 加速 Go map
上一篇文章:使用 Go 构建可扩展的 Wasm 应用
博客索引