Go 部落格
使用 testing/synctest 測試併發程式碼
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
包解決了這個問題。它允許我們重寫這個測試,使其簡單、快速且可靠,而無需更改被測試的程式碼。
該包僅包含兩個函式:Run
和 Wait
。
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 上報告您的經驗,無論是積極的還是消極的。