Go Wiki:LoopvarExperiment
在 Go 1.22 中,Go 變更了 for 迴圈變數的語意,以防止在每次反覆運算的閉包和 goroutine 中意外地共用。
這些新語意也在 Go 1.21 中以變更的初步實作提供,在建置您的程式時設定 GOEXPERIMENT=loopvar
即可啟用。
本頁回答了有關此變更的常見問題。
我要如何嘗試此變更?
在 Go 1.22 及之後的版本,變更會由模組在 go.mod 檔案中的語言版本來控制。如果語言版本為 go1.22 或之後的版本,模組將會使用新的迴圈變數語意。
使用 Go 1.21,使用 GOEXPERIMENT=loopvar
建置您的程式,例如
GOEXPERIMENT=loopvar go install my/program
GOEXPERIMENT=loopvar go build my/program
GOEXPERIMENT=loopvar go test my/program
GOEXPERIMENT=loopvar go test my/program -bench=.
...
這個問題解決了什麼?
假設有如下的迴圈
func TestAllEvenBuggy(t *testing.T) {
testCases := []int{1, 2, 4, 6}
for _, v := range testCases {
t.Run("sub", func(t *testing.T) {
t.Parallel()
if v&1 != 0 {
t.Fatal("odd v", v)
}
})
}
}
這項測試的目的是檢查所有測試案例是否為偶數(其實不是!),但它使用舊語意通過了。問題在於 t.Parallel 會停止閉包並讓迴圈繼續執行,然後在 TestAllEvenBuggy
回傳時並行執行所有閉包。當閉包中的 if 陳述式執行時,迴圈已經結束,且 v 具有其最後反覆運算的值 6。現在所有四個子測試都並行繼續執行,並且都檢查 6 是否為偶數,而不是檢查每個測試案例。
這個問題的另一種變型是
func TestAllEven(t *testing.T) {
testCases := []int{0, 2, 4, 6}
for _, v := range testCases {
t.Run("sub", func(t *testing.T) {
t.Parallel()
if v&1 != 0 {
t.Fatal("odd v", v)
}
})
}
}
此測試並未錯誤通過,因為 0、2、4 和 6 都是偶數,但它也未測試其是否正確處理 0、2 和 4。如同 TestAllEvenBuggy
,它測試 6 四次。
此錯誤的另一種不常見但仍然頻繁的形式是在 3 句 for 迴圈中擷取迴圈變數
func Print123() {
var prints []func()
for i := 1; i <= 3; i++ {
prints = append(prints, func() { fmt.Println(i) })
}
for _, print := range prints {
print()
}
}
此程式看起來會印出 1、2、3,但實際上印出 4、4、4。
這種無意間的共用錯誤影響所有 Go 程式設計師,無論他們剛開始學習 Go 還是已使用它長達十年。此問題的討論是 Go 常見問題集中最早的條目之一。
以下是 由這種錯誤引起的實際問題的公開範例,來自 Let's Encrypt。有問題的程式碼說明
// authz2ModelMapToPB converts a mapping of domain name to authz2Models into a
// protobuf authorizations map
func authz2ModelMapToPB(m map[string]authz2Model) (*sapb.Authorizations, error) {
resp := &sapb.Authorizations{}
for k, v := range m {
// Make a copy of k because it will be reassigned with each loop.
kCopy := k
authzPB, err := modelToAuthzPB(&v)
if err != nil {
return nil, err
}
resp.Authz = append(resp.Authz, &sapb.Authorizations_MapElement{Domain: &kCopy, Authz: authzPB})
}
return resp, nil
}
請注意迴圈主體結尾處使用的 kCopy := k
來防範 &kCopy
。遺憾的是,事實證明 modelToAuthzPB
持有 v
中幾個欄位的指標,這是從此迴圈中讀取時無法知道的。
此錯誤最初的影響是 Let's Encrypt 需要 撤銷超過 300 萬張不當發行的憑證。他們最終並未這麼做,因為對網際網路安全會產生負面影響,而是 爭取例外,但這讓您了解影響的類型。
有問題的程式碼在寫入時經過仔細檢閱,作者清楚知道潛在問題,因為他們寫了 kCopy := k
,但它仍然有一個重大錯誤,除非您也確切知道 modelToAuthzPB
的作用,否則無法發現該錯誤。
解法是什麼?
解法是讓 for 迴圈中使用 :=
宣告的迴圈變數在每次反覆運算時都是該變數的不同執行個體。這樣,如果值在封閉、goroutine 或其他方式中被擷取或延續至反覆運算之後,稍後對它的參考將看到它在那次反覆運算期間具有的值,而不是被後續反覆運算覆寫的值。
對於範圍迴圈,效果就像每個迴圈主體都以每個範圍變數的 k := k
和 v := v
開始。在上述 Let's Encrypt 範例中,就不需要 kCopy := k
,且由 v
遺漏所造成的錯誤也會得到避免。
對於 3 項式的 for 迴圈,效果就像每個迴圈內文以 i := i
開頭,然後在迴圈內文末尾發生反向指定,將每個疊代的 i
複製回用於準備下一個疊代的 i
。聽起來很複雜,但實際上所有常見的 for 迴圈慣用語法都可以完全照原本的方式運作。迴圈行為變動的唯一時間是 i
被擷取並與其他東西共用時。例如,以下程式碼就像一直以來的運行方式
for i := 0;; i++ {
if i >= len(s) || s[i] == '"' {
return s[:i]
}
if s[i] == '\\' { // skip escaped char, potentially a quote
i++
}
}
如需完整詳細資料,請參閱設計文件。
此變更會破壞程式嗎?
是的,可以撰寫會因這個變更而損壞的程式。例如,以下是使用單元素映射彙總串列中值的令人驚訝的方法
func sum(list []int) int {
m := make(map[*int]int)
for _, x := range list {
m[&x] += x
}
for _, sum := range m {
return sum
}
return 0
}
端賴於迴圈中只有一個 x
,因此 &x
在每個疊代中都相同。有了新的語意,x
逸出疊代,因此 &x
在每個疊代中都不同,而且映射現在有多個項目,而非單一項目。
以下是列印值 0 到 9 的令人驚訝的方法
var f func()
for i := 0; i < 10; i++ {
if i == 0 {
f = func() { print(i) }
}
f()
}
端賴於在第一次疊代時初始化的 f
每一次呼叫時都會「看到」新的 i
值。有了新的語意,它會將 0 列印十次。
雖然可以使用新的語意建構會損壞的虛擬程式,我們尚未看到任何執行錯誤的實際程式。
C# 在 C# 5.0 中進行了類似的變更,他們也回報表示因變更而造成的問題非常少見。
變更會破壞實際程式的機率有多高?
根據經驗,幾乎從不會。在 Google 的程式碼庫上進行測試後,發現修正了許多測試。它還找出一些有缺陷的測試,這些測試因為迴圈變數和 t.Parallel
之間不良互動而傳遞不正確,例如在上述的 TestAllEvenBuggy
中。我們重新撰寫了這些測試以修正它們。
我們的經驗顯示,新的語意比起破壞正確的程式碼,更常修正有問題的程式碼。新的語意僅在每 8,000 個測試套件中約造成 1 次測試失敗(所有程式碼都錯誤通過測試),但在整個程式碼庫上執行更新後的 Go 1.20 loopclosure
檢驗檢查會以更高的比率標記測試:400 次中的 1 次(8,000 次中的 20 次)。loopclosure
檢查器沒有誤報:所有報告都是原始程式碼樹中 t.Parallel
的錯誤使用。也就是說,約 5% 標記的測試像是 TestAllEvenBuggy
;其他 95% 像是 TestAllEven
:尚未測試預期的內容,但即使迴圈變數錯誤已修正,仍正確測試正確的程式碼。
從 2023 年 5 月初開始,Google 已在標準生產工具鏈對所有 for 迴圈套用新的迴圈語意,未報告任何問題(並獲得許多讚賞)。
如需瞭解更多有關我們在 Google 的經驗,請參閱 此撰寫內容。
我們也 在 Kubernetes 中嘗試新的迴圈語意。它識別出兩個因為底層程式碼中潛在迴圈變數範圍相關錯誤而造成的新失敗測試。相比之下,將 Kubernetes 從 Go 1.20 更新到 Go 1.21 會因為依賴 Go 本身的未記錄行為而識別出三個新失敗測試。與一般的版本更新相比,因迴圈變數變更而失敗的兩個測試並不會造成顯著的額外負擔。
此變更是否會因為造成更多配置而使程式變慢?
大多數迴圈都不受影響。僅有在迴圈變數取得位址(&i
)或被封裝程式捕獲時,迴圈才會以不同的方式編譯。
即使是受影響的迴圈,編譯器的逃逸分析也可能會判定迴圈變數仍可堆疊配置,這代表不會有新的配置。
不過,在某些情況下,會新增一個額外配置。有時,額外配置會內含於修正潛在錯誤。例如,Print123 現在會配置三個單獨的整數(出現在封裝程式中)而非一個,這是因為需要在迴圈結束後列印三個不同的值。在其他罕見情況下,迴圈使用共用變數是正確的,使用個別變數也是正確的,但現在配置 N 個不同變數而非一個。在執行非常大量的迴圈時,這可能會導致速度變慢。此類問題在記憶體配置剖析中應該會很明顯(使用 pprof --alloc_objects
)。
公共“彎曲”基準評量組的基準測試顯示在整體上沒有顯著的效能差異,而且我們也沒有觀察到在 Google 的內部實際使用情況中出現任何效能問題。我們預計大多數程式都不會受到影響。
變更如何部署?
與 Go 的一般 相容性方法 一致,新的 for 迴圈語意只會應用於在包含宣告 Go 1.22 或更新版本的 go
行的模組中編譯的套件,例如 go 1.22
或 go 1.23
。這個保守的方法可以確保由於採用新的 Go 工具鏈而沒有程式會改變行為。相反,每個模組作者可以自行控制他們的模組何時變更為新的語意。
GOEXPERIMENT=loopvar
試用機制並未使用宣告的 Go 語言版本:它無條件地將新的語意套用至程式中的每個 for 迴圈。這會提供最差的情況,有助於找出變更可能造成的最大影響。
我可以查看程式中哪些地方會受變更影響的清單嗎?
可以。您可以在命令列中使用 -gcflags=all=-d=loopvar=2
編譯。這會為每個編譯方式不同的迴圈印出警告樣式的輸出訊息,例如
$ go build -gcflags=all=-d=loopvar=2 cmd/go
...
modload/import.go:676:7: loop variable d now per-iteration, stack-allocated
modload/query.go:742:10: loop variable r now per-iteration, heap-allocated
all=
會印出關於建置中所有套件的變更。如果您略過 all=
,如同 -gcflags=-d=loopvar=2
,只有在命令列中命名的套件(或當前目錄中的套件)會發出診斷訊息。
我的測試因為變更而失敗。我該如何除錯?
一個名為 bisect
的新工具可以在程式的不同次集中啟用變更,以找出使用變更編譯時,哪個特定的迴圈觸發了測試失敗。如果您有一個失敗的測試,bisect
會找出造成問題的特定迴圈。使用
go install golang.org/x/tools/cmd/bisect@latest
bisect -compile=loopvar go test
請參閱 此則留言的 bisect transcript 區段 以取得真實世界的範例,並參閱 bisect 文件 以取得更多詳細資訊。
這是否表示我以後在我的迴圈中不再需要撰寫 x := x 了?
在您更新模組以使用 go1.22 或更新版本之後,是的。
此內容是 Go Wiki 的一部份。