Go Wiki: LoopvarExperiment
Go 1.22では、反復ごとにクロージャとゴルーチンでの意図しない共有を防ぐために、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の学習を始めたばかりのプログラマーにも、10年間使用しているプログラマーにも影響します。この問題に関する議論は、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 := k`はループ本体の最後に使用される`&kCopy`に対して保護していますが、残念ながら、`modelToAuthzPB`は`v`のいくつかのフィールドへのポインタを保持しており、このループを読むだけでは知ることは不可能です。
このバグの最初の影響は、Let’s Encryptが300万を超える不正に発行された証明書を取り消す必要があったことです。インターネットセキュリティへの悪影響のため、最終的にはそうしませんでした。例外を主張する代わりに、それがどのような影響を与えるかを示しています。
問題のコードは記述時に注意深くレビューされており、作者は`kCopy := k`を書いているため、潜在的な問題を明らかに認識していましたが、それでも重大なバグがあり、`modelToAuthzPB`が何をしているのかを正確に知っていない限りは見ることができません。
解決策は何ですか?
解決策は、`:=`を使用してforループで宣言されたループ変数を、各反復で異なる変数のインスタンスにすることです。このようにして、値がクロージャまたはゴルーチンでキャプチャされたり、反復よりも長持ちしたりする場合、それに対する後の参照は、その反復中に持っていた値を参照し、後の反復によって上書きされた値を参照しません。
rangeループの場合、各ループ本体は、各range変数に対して`k := k`と`v := 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++
}
}
詳細については、設計ドキュメントを参照してください。
この変更によってプログラムが壊れる可能性はありますか?
はい、この変更によって壊れる可能性のあるプログラムを作成することは可能です。たとえば、1要素のマップを使用してリストの値を追加する驚くべき方法を次に示します。
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`は異なり、マップには1つのエントリの代わりに複数のエントリがあります。
そして、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ループに適用された新しいループセマンティクスを使用して実行しており、1件も問題が報告されていません(そして多くの称賛があります)。
Googleでの経験の詳細については、この記述を参照してください。
また、Kubernetesで新しいループセマンティクスを試しました。基盤となるコードの潜在的なループ変数のスコープに関するバグにより、2つの新たに失敗するテストが特定されました。比較として、KubernetesをGo 1.20からGo 1.21に更新すると、Go自体における文書化されていない動作への依存関係のために、3つの新たに失敗するテストが特定されました。ループ変数の変更によって失敗する2つのテストは、通常のリリース更新と比較して、大きな新しい負担ではありません。
変更によって、より多くの割り当てが発生してプログラムが遅くなる可能性はありますか?
ほとんどのループは影響を受けません。ループ変数のアドレスが取得された場合(`&i`)、またはクロージャによってキャプチャされた場合にのみ、ループは異なる方法でコンパイルされます。
影響を受けるループについても、コンパイラのエスケープ分析によって、ループ変数をスタックに割り当てることができる可能性があり、新しい割り当ては必要ありません。
ただし、場合によっては、追加の割り当てが追加されます。場合によっては、追加の割り当ては、潜在的なバグを修正するために不可欠です。たとえば、Print123は現在、3つの別々のint(クロージャ内にあることがわかります)を1つの代わりに割り当てており、ループが終了した後に3つの異なる値を出力するために必要です。まれな他のケースでは、共有変数を使用してループが正しく、別々の変数を使用していても正しくても、1つの変数の代わりにN個の異なる変数が割り当てられる場合があります。非常にホットなループでは、これが減速の原因となる可能性があります。このような問題は、メモリ割り当てプロファイル(`pprof --alloc_objects`を使用)ではっきりとわかるはずです。
公開されている「bent」ベンチスイートのベンチマークでは、全体を通して統計的に有意なパフォーマンスの違いは見られず、Googleの内部分産環境での使用でもパフォーマンスの問題は見られていません。ほとんどのプログラムは影響を受けないと予想されます。
変更はどのように展開されますか?
Goの一般的な互換性へのアプローチと一致して、新しいforループのセマンティクスは、コンパイルされているパッケージが、`go 1.22`や`go 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トランスクリプトセクション、詳細についてはbisectのドキュメントを参照してください。
これは、ループ内で`x := x`をもう書く必要がないという意味ですか?
モジュールをgo1.22以降のバージョンを使用するように更新した後であれば、はい。
このコンテンツはGo Wikiの一部です。