Go 部落格

修復 Go 1.22 中的 for 迴圈

David Chase 和 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 執行時會發生什麼?)給出了這個例子,並指出“在使用閉包和併發時可能會產生一些困惑。”

儘管併發經常涉及其中,但並非必須。以下示例存在相同的問題,但沒有 goroutine:

func main() {
    var prints []func()
    for i := 1; i <= 3; i++ {
        prints = append(prints, func() { fmt.Println(i) })
    }
    for _, print := range prints {
        print()
    }
}

這類錯誤已在許多公司導致生產問題,包括 Let's Encrypt 的公開記錄問題。在該例項中,對迴圈變數的意外捕獲 spread 到了多個函式中,並且更難以注意到。

// 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` 分析器傾向於假陰性,僅在確定存在問題時報告,但會錯過其他問題。其他檢查器傾向於假陽性,指責正確程式碼不正確。我們對開源 Go 程式碼中新增 `x := x` 行的 commit 進行了分析,希望能找到 bug 修復。結果我們發現添加了許多不必要的行,這反而表明流行的檢查器具有顯著的假陽性率,但開發者仍然新增這些行以讓檢查器滿意。

我們發現的一對示例尤其具有啟發性:

此 diff 存在於一個程式中:

     for _, informer := range c.informerMap {
+        informer := informer
         go informer.Run(stopCh)
     }

此 diff 存在於另一個程式中:

     for _, a := range alarms {
+        a := a
         go a.Monitor(b)
     }

這兩個 diff 中有一個是 bug 修復;另一個是不必要的更改。除非您瞭解所涉及的型別和函式,否則您無法分辨哪個是哪個。

解決方案

對於 Go 1.22,我們計劃將 `for` 迴圈更改為使這些變數具有每次迭代的作用域,而不是每個迴圈的作用域。此更改將修復上述示例,使其不再是 buggy 的 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

我們在 2023 年 5 月初開始,對 Google 的內部 Go 工具鏈進行了補丁,強制在所有構建中啟用此模式,並且在過去的四個月裡,我們收到了零個關於生產程式碼中出現任何問題的報告。

您也可以透過在程式頂部包含 `// GOEXPERIMENT=loopvar` 註釋,在 Go playground 上透過測試程式來更好地理解語義,例如 此程式。(此註釋僅在 Go playground 中有效。)

修復 buggy 的測試

儘管我們沒有生產問題,但在為切換做準備時,我們確實不得不糾正許多 buggy 的測試,這些測試並沒有測試它們所認為的,比如這個:

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 迴圈會暴露這種 buggy 的測試。

為了幫助為這種發現做準備,我們在 Go 1.21 中提高了 `loopclosure` 分析器的精度,使其能夠識別並報告此問題。您可以在 Go playground 上的 此程式 中看到報告。如果 `go vet` 在您的測試中報告了此類問題,修復它們將使您為 Go 1.22 做好更好的準備。

如果您遇到其他問題,FAQ 提供了指向示例的連結以及有關使用我們編寫的工具來識別當新語義應用於特定迴圈時導致測試失敗的詳細資訊。

更多資訊

有關此更改的更多資訊,請參閱 設計文件FAQ

下一篇文章: 解構型別引數
上一篇文章: WASI 在 Go 中的支援
部落格索引