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 := kv := 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.22go 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 的一部分。