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 四次。
這種 bug 的另一種不那麼常見但仍然頻繁的形式是在三段式 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。
這種意外共享 bug 困擾著所有 Go 程式設計師,無論他們是剛開始學習 Go 還是已經使用了十年。關於這個問題的討論是 Go FAQ 中最早的條目之一。
以下是來自 Let's Encrypt 的 一個由這種 bug 導致的生產問題的公開示例。相關程式碼如下:
// 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
中幾個欄位的指標,這在閱讀此迴圈時無法得知。
此 bug 的最初影響是 Let's Encrypt 需要 撤銷超過 300 萬張錯誤簽發的證書。他們最終沒有這樣做,因為這會對網際網路安全產生負面影響,而是 爭取獲得豁免,但這讓你對這種影響有所瞭解。
相關程式碼在編寫時經過了仔細審查,作者顯然意識到了潛在問題,因為他們編寫了 kCopy := k
,但它仍然存在一個重大 bug,除非你確切知道 modelToAuthzPB
的作用,否則這個 bug 是不可見的。
解決方案是什麼?
解決方案是使在 for 迴圈中使用 :=
宣告的迴圈變數在每次迭代中都是變數的不同例項。這樣,如果值被閉包或 goroutine 捕獲或以其他方式超出了迭代生命週期,則以後對它的引用將看到它在該迭代期間的值,而不是被後續迭代覆蓋的值。
對於 range 迴圈,效果就像每個迴圈體都以 k := k
和 v := v
開始,用於每個 range 變數。在上面的 Let's Encrypt 示例中,kCopy := k
將是不必要的,並且可以避免由於缺少 v := v
引起的 bug。
對於三段式 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
之間的錯誤互動而錯誤透過的 bug 測試,就像上面 TestAllEvenBuggy
中那樣。我們重寫了這些測試以糾正它們。
我們的經驗表明,新語義修復 bug 程式碼的頻率遠高於破壞正確程式碼的頻率。新語義僅在約 8000 個測試包中導致了 1 個測試失敗(所有這些都是錯誤透過的測試),但在我們整個程式碼庫中執行更新的 Go 1.20 loopclosure
vet 檢查以更高的速率標記了測試:1/400 (8000 中的 20)。loopclosure
檢查器沒有誤報:所有報告都是我們原始碼樹中 t.Parallel
的 bug 用法。也就是說,約 5% 的標記測試類似於 TestAllEvenBuggy
;其他 95% 類似於 TestAllEven
:尚未測試其預期功能,但即使修復了迴圈變數 bug,也是對正確程式碼的正確測試。
自 2023 年 5 月初以來,Google 一直在標準生產工具鏈中對所有 for 迴圈應用新的迴圈語義,沒有報告任何問題(並獲得了許多讚揚)。
有關我們在 Google 經驗的更多詳情,請參閱 這篇綜述。
我們還在 Kubernetes 中嘗試了新的迴圈語義。它識別出兩個新失敗的測試,原因是底層程式碼中潛在的迴圈變數作用域相關 bug。相比之下,將 Kubernetes 從 Go 1.20 更新到 Go 1.21 識別出三個新失敗的測試,原因是依賴於 Go 本身未文件化的行為。由於迴圈變數更改而導致的兩個測試失敗與普通的版本更新相比,並不是一個顯著的新負擔。
此更改會透過導致更多分配而使程式變慢嗎?
絕大多數迴圈不受影響。只有當迴圈變數取地址 (&i
) 或被閉包捕獲時,迴圈才會以不同的方式編譯。
即使對於受影響的迴圈,編譯器的逃逸分析也可能確定迴圈變數仍然可以在棧上分配,這意味著沒有新的分配。
但是,在某些情況下,會增加額外的分配。有時,額外的分配是修復潛在 bug 所固有的。例如,Print123 現在分配了三個獨立的 int(結果是在閉包內部)而不是一個,這對於在迴圈結束後列印三個不同的值是必要的。在極少數其他情況下,迴圈可能在共享變數下是正確的,並且在獨立變數下仍然是正確的,但現在分配了 N 個不同的變數而不是一個。在非常熱的迴圈中,這可能會導致速度變慢。這些問題應該在記憶體分配配置檔案中顯而易見(使用 pprof --alloc_objects
)。
對公共“bent”基準測試套件的基準測試顯示,總體上沒有統計學上顯著的效能差異,我們也沒有在 Google 的內部生產使用中觀察到任何效能問題。我們預計大多數程式不會受到影響。
此更改如何部署?
與 Go 一般的 相容性方法 一致,新的 for 迴圈語義僅適用於正在編譯的包來自包含宣告 Go 1.22 或更高版本(如 go 1.22
或 go 1.23
)的 go
行的模組。這種保守的方法確保沒有任何程式會因為簡單地採用新的 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 副本部分,有關更多詳細資訊,請參閱 bisect 文件。
這是否意味著我的迴圈中不再需要編寫 x := x
了?
在您將模組更新為使用 go1.22 或更高版本之後,是的。
此內容是 Go Wiki 的一部分。