Go Wiki: LoopvarExperiment

Go 1.22 では、Go は for ループ変数のセマンティクスを変更し、イテレーションごとのクロージャやゴルーチンにおける意図しない共有を防ぐようにしました。

新しいセマンティクスは 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 を持っています。これで、4つのサブテストすべてが並列実行を続け、それぞれがテストケースをチェックする代わりに、すべてが 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 を 4 回テストしています。

このバグの、あまり一般的ではありませんが依然として頻繁に発生する別の形式は、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 プログラマーから、10 年間使用しているプログラマーまで、すべての Go プログラマーに影響を与えます。この問題に関する議論は、Go FAQ の最も古いエントリの 1 つです。

以下は、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 から守るための kCopy := k に注目してください。残念ながら、modelToAuthzPBv 内のいくつかのフィールドへのポインタを保持していたことが判明しました。これは、このループを読んだだけでは知ることは不可能です。

このバグの初期の影響は、Let's Encrypt が 300万を超える不正発行された証明書を失効させる必要があったことです。彼らはインターネットセキュリティに与える負の影響のため、結局それは行わず、代わりに 例外を主張しましたが、これによりどのような影響があったかを理解できます。

問題のコードは記述時に慎重にレビューされ、著者は kCopy := k と書いたことから、潜在的な問題を明らかに認識していました。それでも、modelToAuthzPB が正確に何をするかを知らない限り見えない、重大なバグが依然として存在しました

解決策は何ですか?

解決策は、:= を使用して for ループで宣言されたループ変数を、各イテレーションで異なる変数のインスタンスにすることです。これにより、値がクロージャまたはゴルーチンでキャプチャされたり、イテレーションを超えて存続したりした場合でも、それに対する後続の参照は、後のイテレーションによって上書きされた値ではなく、そのイテレーション中に持っていた値を見ることになります。

range ループの場合、各ループ本体が各 range 変数に対して k := kv := v で始まるかのような効果があります。上記の Let's Encrypt の例では、kCopy := k は不要になり、v := 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 が 1 つしかないため、&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 が 10 回出力されます。

新しいセマンティクスを使用して壊れる人工的なプログラムを構築することは可能ですが、実際に不正に実行されるプログラムはまだ見ていません。

C# は C# 5.0 で同様の変更を行い、彼らも報告しているように、この変更によって引き起こされた問題は非常に少なかったです。

より壊れる可能性のある、または驚くべきケースは、こちらこちらに示されています。

この変更が実際のプログラムを壊す頻度はどれくらいですか?

経験的には、ほとんどありません。Google のコードベースでのテストでは、多くのテストが修正されました。また、上記の TestAllEvenBuggy のように、ループ変数と t.Parallel の間の悪い相互作用のために誤ってパスしていたいくつかのバグのあるテストも特定されました。それらのテストは修正するために書き直されました。

私たちの経験では、新しいセマンティクスは正しいコードを壊すよりも、バグのあるコードを修正する方がはるかに多いことを示唆しています。新しいセマンティクスは、約 8,000 のテストパッケージのうち 1 つ (すべてが誤ってパスしていたテスト) でのみテスト失敗を引き起こしましたが、更新された Go 1.20 の loopclosure vet チェックをコードベース全体で実行したところ、テストがはるかに高い割合でフラグが立てられました: 400 分の 1 (8,000 分の 20)。loopclosure チェッカーには偽陽性はありません。すべてのレポートは、ソースツリー内の t.Parallel のバグのある使用法です。つまり、フラグが立てられたテストの約 5% が TestAllEvenBuggy のようなものでした。残りの 95% は TestAllEven のようなものでした。意図したものをまだテストしていませんが、ループ変数のバグが修正されていても正しいコードの正しいテストでした。

Google は、2023 年 5 月初旬から標準のプロダクションツールチェーンですべての for ループに新しいループセマンティクスを適用して実行しており、問題の報告は一切ありません (そして多くの歓声がありました)。

Google での経験の詳細については、この文書を参照してください。

Kubernetes でも新しいループセマンティクスを試しました。これにより、基になるコードの潜在的なループ変数スコープ関連のバグにより、新たに 2 つのテストが失敗しました。比較のために、Kubernetes を Go 1.20 から Go 1.21 に更新したところ、Go 自体の文書化されていない動作への依存により、新たに 3 つのテストが失敗しました。ループ変数の変更による 2 つのテストの失敗は、通常のリリースアップデートと比較して、それほど大きな新たな負担ではありません。

この変更により、割り当てが増えることでプログラムが遅くなりますか?

ほとんどのループは影響を受けません。ループ変数のアドレスが取得される (&i) か、クロージャによってキャプチャされる場合にのみ、ループは異なる方法でコンパイルされます。

影響を受けるループであっても、コンパイラのエスケープ解析によってループ変数をスタックに割り当てることができると判断される場合があり、これは新しい割り当てがないことを意味します。

ただし、場合によっては、追加の割り当てが追加されます。場合によっては、追加の割り当ては潜在的なバグを修正するために固有のものです。たとえば、Print123 は現在、1 つではなく 3 つの個別の int (クロージャ内で、であることが判明) を割り当てています。これは、ループが終了した後に 3 つの異なる値を出力するために必要です。まれに、ループが共有変数で正しく、個別の変数でも正しいが、1 つではなく N 個の異なる変数を割り当てている場合があります。非常にホットなループでは、これにより速度が低下する可能性があります。このような問題は、メモリ割り当てプロファイル (pprof --alloc_objects を使用) で明らかになるはずです。

公開されている「bent」ベンチスイートのベンチマークでは、全体的に統計的に有意なパフォーマンス差は示されず、Google の社内プロダクション使用でもパフォーマンスの問題は観察されていません。ほとんどのプログラムは影響を受けないと予想されます。

この変更はどのように展開されますか?

Go の一般的な互換性へのアプローチと一貫して、新しい for ループのセマンティクスは、コンパイルされるパッケージが go 1.22go 1.23 のように Go 1.22 以降を宣言する 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 transcript セクションを、詳細についてはbisect ドキュメントを参照してください。

これは、ループ内で x := x と書く必要がなくなるということですか?

モジュールを go1.22 以降のバージョンを使用するように更新した後であれば、はい。


このコンテンツはGo Wikiの一部です。