Shogo's Blog

Jun 22, 2023 - 3 minute read - go golang

Go 1.22で導入されるforループ変数の変更

Go 1.21 Release Candidateで、 forループ変数のセマンティクス変更の予定をしりました。 導入の背景や影響について、WikiのLoopvarExperimentで説明されています。

地味にインパクトが大きそうだったので、内容を理解するために和訳しました。 といっても訳の大半はChatGPT ChatGPT May 24 Versionのものです。便利。 多少僕の修正も入ってます。

以下LoopvarExperimentのリビジョンdce06fbの和訳です。


Go 1.22では、Goチームはforループ変数のセマンティクスを変更し、繰り返し毎のクロージャやゴルーチンにおける意図しない共有を防止することを検討しています。 Go 1.21には、この変更の予備的な実装が含まれており、プログラムをビルドする際にGOEXPERIMENT=loopvarを設定することで有効になります。 変更の影響を理解するのに協力していただける方々には、GOEXPERIMENT=loopvarを使用して試してみていただき、遭遇した問題や成功した点についてご報告いただけると幸いです。

このページでは、変更に関するよくある質問にお答えします。

この変更を試すにはどうすればいいですか?

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)
				}
			})
		}
	}

このテストは、すべてのテストケースが偶数であるかを確認することを目的としていますが、GOEXPERIMENT=loopvarを使用しなくてもパスします。 問題は、t.Parallelがクロージャを停止させ、ループを継続させ、そしてTestAllEvenが戻るときにすべてのクロージャを並列で実行することです。 クロージャ内の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回テストしてしまいます。

このバグのもう1つの一般的な形式は、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プログラマに影響を与えます。 この問題の議論は、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 := 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] == '\\' { // エスケープされた文字(おそらく引用符)をスキップ
			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
}

この例では、ループ内には1つのxしかないため、各イテレーションで&xが同じになることを前提としています。 しかし、GOEXPERIMENT=loopvarでは、xがイテレーションからエスケープするため、各イテレーションごとに&xが異なる値となります。 マップには単一のエントリーではなく複数のエントリーが含まれるようになります。

以下は、0から9までの値を出力する意外な方法です。

	var f func()
	for i := 0; i < 10; i++ {
		if i == 0 {
			f = func() { print(i) }
		}
		f()
	}

この例では、最初のイテレーションで初期化されたfは、呼び出されるたびにiの新しい値を「見る」ということに依存しています。GOEXPERIMENT=loopvarを使用すると、0が10回出力されます。

GOEXPERIMENT=loopvarを使用して壊れる人工的なプログラムを作成することは可能ですが、まだ実際に誤って実行されるプログラムは見つかっていません。

C#もC# 5.0で同様の変更を行い、彼らも同様の変更による問題はほとんど報告されなかったと報告しています。

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

実証的には、この変更によって実際のプログラムが壊れることはほとんどありません。 Googleのコードベースでのテストでは、多くのテストが修正されました。 また、ループ変数とt.Parallelの間の相互作用の問題により、いくつかのバグのあるテストが誤って合格していることも特定されました。 先ほどの「TestAllEvenBuggy」のようなテストを修正するために、それらのテストを書き直しました。

私たちの経験からは、新しいセマンティクスは正しいコードを壊すよりも、バグのあるコードを修正することの方が遥かに多いということが示されています。 新しいセマンティクスは、テストパッケージの約1/8(すべてが誤って合格しているテスト)でのみテストの失敗を引き起こしました。 ただし、更新されたGo 1.20のloopclosurevetチェックをコードベース全体に適用すると、テストがはるかに高い割合で検出されました。 約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つのテストの失敗は、通常のリリースの更新に比べて重大な負担とはなりません。

プログラムの実行を遅くする可能性がありますか?それによってより多くの割り当てが行われるためですか?

可能性があります。いくつかの場合、追加の割り当ては潜在的なバグの修正に固有のものです。 たとえば、Print123では、ループが終了した後に異なる値を印刷するために、現在は3つの別々のint(クロージャ内部で行われる)を割り当てています。 そのため、1つの代わりにN個の異なる変数を割り当てる必要がありますが、共有変数であっても別々の変数であっても、ループは正しい状態です。 非常に頻繁に実行されるループでは、これにより遅延が発生する場合があります。このような問題は、メモリ割り当てプロファイル(pprof --alloc_objectsを使用)で明らかになるはずです。

公開されている「bent」ベンチマークスイートのベンチマークでは、統計的に有意なパフォーマンスの差は見られなかったため、ほとんどのプログラムに影響はないと予想されます。

もし提案が承認される場合、変更はどのように展開されますか?

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と書く必要がなくなったということですか?

まだそうではありません。しかし、将来のバージョンのGoでは、おそらくGo 1.22であるかもしれませんが、その必要がなくなることを期待しています。

どうすればフィードバックを送れますか?

フィードバックを送る方法は次の通りです。

提案の問題は提案について議論するための場所です。 ただし、直感的または自然なものは何かについての議論は生産的ではないことに注意してください。 異なる人々は、何が直感的または自然であるかについて合理的に異なる意見を持つことがあります。

代わりに、議論に貢献できるもっとも重要なフィードバックは、 実際のコードベースでの変更の使用に関する実証データ です。

  • GOEXPERIMENT=loopvar でいくつのテストが失敗するようになったでしょうか?
  • GOEXPERIMENT=loopvar によって正しいプロダクションコードが壊れたというよりも、バグのあるコードやテストを特定するために使われましたか?
  • 重要な実世界のベンチマークのいずれかが大幅に遅くなったり、メモリを大幅に消費するようになったりしましたか?

このような具体的なデータを議論に寄与することが、もっとも重要なフィードバックです。