Go 部落格
測試時間(及其他非同步性)
在 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)
當我談論測試併發程式碼時,我指的是測試這些型別的非同步操作,包括使用實際時間的操作和不使用實際時間的操作。
測試
測試驗證系統是否按預期執行。有很多術語描述測試型別——單元測試、整合測試等等——但就我們的目的而言,每種測試都歸結為三個步驟
- 設定一些初始條件。
- 告訴被測系統做一些事情。
- 驗證結果。
測試同步函式很簡單
- 你呼叫函式;
- 函式做一些事情並返回;
- 你驗證結果。
然而,測試非同步函式很棘手
- 你呼叫函式;
- 它返回;
- 你等待它完成它所做的一切;
- 你驗證結果。
如果你沒有等待正確的時間量,你可能會發現自己正在驗證一個尚未發生或只部分發生的操作的結果。這從來沒有好結果。
當你想要斷言某事 沒有 發生時,測試非同步函式尤其棘手。你可以驗證該事尚未發生,但你如何確定它以後不會發生呢?
一個例子
為了讓事情更具體一些,我們來看一個真實的例子。再次考慮 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
,有兩個明顯的測試可以編寫。
- 上下文在截止日期 之前沒有 被取消。
- 上下文在截止日期 之後被 取消。
讓我們編寫一個測試。
為了使程式碼量稍微不那麼讓人不知所措,我們只測試第二種情況:截止日期過期後,上下文被取消。
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")
}
}
這個測試很簡單
- 使用
context.WithDeadline
建立一個截止日期為未來一秒的上下文。 - 等待直到截止日期。
- 驗證上下文是否被取消。
不幸的是,這個測試顯然有問題。它會休眠直到截止日期過期那一刻。我們檢查時,上下文很可能尚未被取消。最好的情況是,這個測試會非常不穩定。
讓我們修復它。
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)
這是一個簡單的例子,但它演示了編寫可測試併發程式碼的兩個基本原則
- 使用模擬時間(如果你使用時間)。
- 有某種方法可以等待靜止,這是一種花哨的說法,意思是“所有後臺活動都已停止,系統穩定”。
當然,有趣的問題是我們如何做到這一點。我在這個例子中忽略了細節,因為這種方法有一些很大的缺點。
這很難。使用模擬時鐘並不困難,但識別後臺併發工作何時完成以及何時可以安全地檢查系統狀態卻很困難。
你的程式碼變得不那麼地道了。你不能使用標準的 `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 都被持久阻塞時
synctest.Wait
返回。- 如果沒有正在進行的
synctest.Wait
呼叫,模擬時間將立即前進到下一個將喚醒 goroutine 的點。 - 如果沒有任何 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
沒有建構函式,因此我們在第一次呼叫 Go
或 Add
時隱式地與氣泡關聯。
與通道一樣,等待屬於同一氣泡的 WaitGroup
是持久阻塞的,而等待氣泡外部的 WaitGroup
則不是。在屬於不同氣泡的 WaitGroup
上呼叫 Go
或 Add
是一個錯誤。
持久阻塞: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.Listener
或 net.PacketConn
。此外,net.Pipe
返回的 net.Conn
是同步的——每次寫入都會阻塞,直到讀取消耗資料——這不代表真實的網路行為。也許我們應該在標準庫中新增一個好的常用網路介面的模擬實現。
結論
這就是 synctest
包。
我不能說它使併發程式碼的測試變得簡單,因為併發從來都不簡單。它所做的只是讓您能夠使用地道的 Go 和標準的 time 包編寫最簡單的併發程式碼,然後為它編寫快速可靠的測試。
希望你覺得它有用。
上一篇文章:容器感知的 GOMAXPROCS
部落格索引