Go 博客

使用 testing/synctest 测试并发代码

Damien Neil
2025 年 2 月 19 日

Go 的一项标志性功能是内置的并发支持。Goroutines 和 channels 是编写并发程序的简单而有效的基元。

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

在 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 毫秒不算多,但累积起来会影响许多测试。

这个测试也很不稳定:10 毫秒对于一台快速的计算机来说很长,但在共享和过载的 CI 系统上,看到持续几秒钟的暂停并不罕见。

我们可以通过牺牲速度来使测试更稳定,也可以通过牺牲稳定性来使测试更快,但我们无法同时使其快速和可靠。

引入 testing/synctest 包

testing/synctest 包解决了这个问题。它允许我们重写这个测试,使其简单、快速且可靠,而无需更改被测试的代码。

该包仅包含两个函数:RunWait

Run 在新 goroutine 中调用一个函数。这个 goroutine 以及它启动的任何 goroutine 都存在于一个我们称之为气泡的隔离环境中。Wait 等待当前 goroutine 的气泡中的所有 goroutine 阻塞在气泡中的另一个 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 函数等待调用者气泡中的所有 goroutine 阻塞。当它返回时,我们知道 context 包要么已经调用了函数,要么在我们采取进一步行动之前不会调用它。

此测试现在既快速又可靠。

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

Race detector 理解 Wait 调用,并且此测试在运行 -race 时通过。如果我们删除第二个 Wait 调用,race detector 将正确报告测试中的数据竞争。

测试时间

并发代码经常处理时间。

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

testing/synctest 包简化了使用时间的代码的测试。

Run 启动的气泡中的 Goroutines 使用模拟时钟。在气泡内,time 包中的函数在模拟时钟上运行。当所有 goroutines 都阻塞时,气泡中的时间会前进。

为了演示,让我们为 context.WithTimeout 函数编写一个测试。WithTimeout 创建一个 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 包的计时器运行完毕。

阻塞与气泡

testing/synctest 中的一个关键概念是气泡进入持久阻塞状态。当气泡中的每个 goroutine 都阻塞,并且只能被气泡中的另一个 goroutine 解除阻塞时,就会发生这种情况。

当气泡持久阻塞时:

  • 如果存在挂起的 Wait 调用,它将返回。
  • 否则,时间会前进到下一个可能解除 goroutine 阻塞的时间(如果有)。
  • 否则,气泡会死锁,Run 会 panic。

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

使 goroutine 持久阻塞的操作的完整列表是:

  • 发送或接收 nil channel
  • 发送或接收阻塞在同一气泡内创建的 channel 上
  • select 语句,其中每个 case 都持久阻塞
  • time.Sleep
  • sync.Cond.Wait
  • sync.WaitGroup.Wait

Mutexes

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

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

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

Channels

在气泡内创建的 Channel 的行为与在气泡外创建的 Channel 不同。

Channel 操作仅在 Channel 被气泡化(在气泡中创建)时才持久阻塞。从气泡外部操作被气泡化的 Channel 会导致 panic。

这些规则确保 goroutine 仅在与气泡内的 goroutines 通信时才持久阻塞。

I/O

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

网络读取可能会被气泡外部的写入解除阻塞,甚至可能来自其他进程。即使网络连接的唯一写入器也位于同一气泡中,运行时也无法区分正在等待更多数据到达的连接与内核已接收到数据并正在传输它的连接。

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

气泡生命周期

Run 函数在一个新气泡中启动一个 goroutine。当气泡中的所有 goroutines 都退出时,它返回。如果气泡被持久阻塞且无法通过推进时间来解除阻塞,它将 panic。

要求气泡中的所有 goroutines 在 Run 返回之前退出意味着测试必须小心在完成之前清理任何后台 goroutines。

测试网络代码

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

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

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

通常,通信客户端和服务器的测试可以使用回送网络连接。然而,在使用 testing/synctest 时,我们通常希望使用模拟网络连接,以便我们能够检测到所有 goroutines 何时阻塞在网络上。我们将通过创建一个使用 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,将发送到服务器的 body 复制到一个 strings.Builder 中,等待气泡中的所有 goroutines 阻塞,并验证我们尚未从 body 中读取任何内容。

如果我们忘记 synctest.Wait 调用,race detector 将正确地抱怨数据竞争,但有了 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”响应来完成请求。

在此测试期间,我们启动了几个 goroutines。synctest.Run 调用将在所有 goroutines 退出后返回。

        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 应用程序
博客索引