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 應用
部落格索引