原文在這裡。
由 David Chase and Russ Cox 釋出於2023年9月19日
Go 1.21 版本包含了對 for 迴圈作用域的預覽更改,我們計劃在 Go 1.22 中釋出此更改,以消除其中一種最常見的 Go 錯誤。
問題
如果你寫過一定量的 Go 程式碼,你可能犯過一個錯誤,即在迭代結束後仍然保留對迴圈變數的引用,此時它會取一個你不希望的新值。例如,思考下面的程式:
func main() {
done := make(chan bool)
values := []string{"a", "b", "c"}
for _, v := range values {
go func() {
fmt.Println(v)
done <- true
}()
}
// wait for all goroutines to complete before exiting
for _ = range values {
<-done
}
}
這三個建立的 goroutine 都在列印同一個變數 v,所以它們通常會列印出 "c"、"c"、"c",而不是以某種順序列印出 "a"、"b" 和 "c"。
Go FAQ 中的條目 "What happens with closures running as goroutines?" 給出了這個例子,並指出 "在使用閉包與併發時可能會引起一些困惑"。
儘管上面的問題通常都涉及併發,但也不全是。這個例子雖然沒有使用 goroutine,但仍然存在相同的問題:
func main() {
var prints []func()
for i := 1; i <= 3; i++ {
prints = append(prints, func() { fmt.Println(i) })
}
for _, print := range prints {
print()
}
}
這種錯誤已經在許多公司中引發了生產問題,包括 Lets 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
}
這段程式碼的作者顯然對這個問題有所瞭解,因為他們複製了 k
。但是,事實證明,在構建其結果時,modelToAuthzPB
使用了 v
中欄位的指標,所以迴圈還需要複製 v
。
儘管我們已經編寫了一些工具來識別這些錯誤,但是很難分析變數的引用是否超出了其迭代的範圍。這些工具必須在誤報和漏報之間做出選擇。go vet
和 gopls
使用的 loopclosure
分析器選擇了漏報,只有在確定存在問題時才會報告,但會錯過其他情況。其他檢查器則選擇了誤報,將正確的程式碼誤認為是錯誤的。我們對新增了 x := x
行的開源 Go 程式碼進行了分析,期望找到 bug 修復。然而,我們發現許多不必要的行被新增進去,這表明儘管流行的檢查器存在相當高的誤報率,但開發人員仍然新增這些行來滿足檢查器的要求。
我們發現的一對示例特別有啟發性:
在某個程式中,出現了以下差異:
for _, informer := range c.informerMap {
+ informer := informer
go informer.Run(stopCh)
}
在另一個程式中:
for _, a := range alarms {
+ a := a
go a.Monitor(b)
}
這兩個差異中,一個是 bug 修復,另一個是不必要的更改。除非你對涉及的型別和函式有更多瞭解,否則無法確定哪個是哪個。
修復
在 Go 1.22 中,我們計劃更改 for 迴圈,使這些變數具有每次迭代的作用域,而不是每次迴圈的作用域。這個改變將修復上面的例子,使它們不再是有錯誤的 Go 程式;它將解決由這些錯誤引起的生產問題;並且它將消除需要不準確的工具來提示使用者對其程式碼進行不必要更改的需求。
為了確保與現有程式碼的向後相容性,新的語義將僅適用於在其 go.mod
檔案中宣告瞭 go 1.22
或更高版本的模組中的包。這個每個模組的決策為開發人員提供了對程式碼庫中新語義逐步更新的控制。還可以使用 //go:build
行來控制每個檔案的決策。
舊程式碼將繼續與今天完全相同:修復僅適用於新的或已更新的程式碼。這將使開發人員能夠控制特定包中語義何時發生變化。由於我們的向前相容性工作,Go 1.21 將不會嘗試編譯宣告瞭 go 1.22 或更高版本的程式碼。我們在 Go 1.20.8 和 Go 1.19.13 的點發布版本中包含了一個具有相同效果的特殊情況,因此當釋出 Go 1.22 時,依賴於新語義的程式碼將永遠不會使用舊語義進行編譯,除非人們使用非常舊且不受支援的 Go 版本。
修復預覽
Go 1.21 包含了作用域更改的預覽版本。如果您在環境中設定了 GOEXPERIMENT=loopvar
並編譯您的程式碼,那麼新的語義將應用於所有迴圈(忽略 go.mod 中的 go 行)。例如,要檢查在將新的迴圈語義應用於您的包及其所有依賴項後,您的測試是否仍然透過,您可以執行以下操作:
GOEXPERIMENT=loopvar go test
我們在 Google 內部的 Go 工具鏈中進行了補丁,從 2023 年 5 月初開始,在所有構建過程中強制啟用了這種模式,並且在過去的四個月中,我們沒有收到任何關於生產程式碼的問題報告。
您還可以嘗試一些測試程式,透過在程式頂部包含一個 // GOEXPERIMENT=loopvar
註釋來更好地理解迴圈語義,就像這個程式中一樣。(此註釋僅適用於 Go Playground。)
驗證測試
儘管我們在生產環境中沒有遇到問題,但為了做好準備,我們確實需要糾正許多有問題的測試,這些測試並沒有測試它們認為的內容,就像這個例子一樣:
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)
}
})
}
}
在 Go 1.21 中,這個測試透過是因為 t.Parallel
阻塞了每個子測試,直到整個迴圈完成,然後並行執行所有子測試。當迴圈完成時,v
的值總是 6,而所有子測試都檢查 6 是否為偶數,所以測試透過了。但實際上,這個測試應該失敗,因為 1 不是偶數。修復 for 迴圈暴露了這種有問題的測試。
為了幫助準備這種發現,我們在 Go 1.21 中提高了 loopclosure
分析器的精確性,使其能夠識別和報告這個問題。你可以在 Go Playground 上的這個程式中看到報告。如果 go vet
在你自己的測試中報告了這種問題,修復它們將更好地為 Go 1.22 做準備。
如果你遇到其他問題,FAQ中提供了示例和詳細資訊的連結,可以使用我們編寫的工具來識別在應用新語義時導致測試失敗的具體迴圈。
更多詳情
要了解更多關於這個改變的資訊,請參閱設計文件和常見問題解答(FAQ)。這些資源將提供更詳細的解釋和指導,幫助您更好地理解這個改變以及如何適應它。
宣告:本作品採用署名-非商業性使用-相同方式共享 4.0 國際 (CC BY-NC-SA 4.0)進行許可,使用時請註明出處。
Author: mengbin
blog: mengbin
Github: mengbin92
cnblogs: 戀水無意