- Go 1.22は、
forループ変数をループ全体ではなく反復ごとのスコープに変更し、クロージャが同じ変数を誤ってキャプチャするというGoの代表的なミスを減らそうとしている
- 従来のセマンティクスでは、goroutineがなくても、反復後に実行される関数が同じ
vやiを参照し、最後の値だけを見たり、テストが誤って通過したりすることがある
go vetとgoplsのloopclosureアナライザーは確実なケースだけを検出するため見逃しが発生し、より積極的なチェッカーは誤検出のため不要なx:= xコードを増やす可能性がある
- 新しいセマンティクスは、
go.modでgo 1.22以上を宣言したモジュールにのみ適用され、Go 1.21ではGOEXPERIMENT=loopvarでプレビューを実行できる
- Googleは2023年5月初旬から社内Goツールチェーンでこのモードをすべてのビルドに強制し、4か月間プロダクション上の問題報告はなかったが、誤って書かれたテストは明らかになった
従来のforループにおける変数キャプチャの落とし穴
- Goの従来の
forループ変数はループ全体のスコープを持つため、反復が終わった後にその変数を参照するコードが意図と異なる値を見ることがある
values := []string{"a", "b", "c"}を巡回しながら3つのgoroutineを作ると、各goroutineは反復ごとのvではなく同じ変数vを出力する
- 並行性がなくても同じ問題は起きる
- 反復内で
func() { fmt.Println(i) }をスライスに保存して後で実行すると、各関数は反復ごとの値ではなく同じiを参照する
プロダクション障害とアナライザーの限界
- このようなミスは複数の企業でプロダクション上の問題につながり、Let’s Encryptの公開イシューもその一つである
- Let’s Encryptの事例では、mapの巡回中に
kはkCopy := kでコピーしていたが、modelToAuthzPB(&v)が結果生成の過程でvのフィールドポインタを使っていたため、vも別途コピーする必要があった
- 変数キャプチャが複数の関数にまたがっていたため、問題に気づきにくかった
- 静的解析ツールは、変数が反復後まで生き残るかを判断するのが難しく、誤検出と見逃しの間で妥協せざるを得ない
go vetとgoplsのloopclosureアナライザーは、確実な問題だけを報告することで見逃しを受け入れている
- より積極的なチェッカーは、正しいコードまで誤ったコードとして指摘する可能性がある
- オープンソースのGoコードで
x := x行を追加したコミットを見ると、実際のバグ修正だけでなく不要な変更も多く混ざっていた
- 開発者がチェッカーを満足させるために不要なコードを追加する状況があった
informer := informerとa := aのような2つのdiffのうち、一方だけがバグ修正で、もう一方は不要な変更だったが、型や関数の情報がなければ区別は難しい
Go 1.22の新しいループセマンティクス
- Go 1.22では、
forループ変数が反復ごとに別スコープを持つように変更される予定である
- 先の例はもはやバグのあるGoプログラムではなくなり、このようなミスによって生じるプロダクション上の問題や、不正確な検査ツールの必要性も減る
- 後方互換性のため、新しいセマンティクスは
go.modでgo 1.22以上を宣言したモジュールのパッケージにのみ適用される
- コードベース全体を一度に変更せず、段階的に移行できる
//go:build行でファイル単位の制御も可能である
- 既存コードは現在と同じ意味をそのまま維持する
- 修正は新しいコードや更新されたコードにのみ適用される
- 特定のパッケージでセマンティクスが変わる時点を開発者が制御できる
以前のGoバージョンでの安全策
- Goのforward compatibility作業により、Go 1.21は
go 1.22以上を宣言したコードをコンパイルしない
- Go 1.20.8とGo 1.19.13のポイントリリースにも同じ効果を持つ特別な処理が入る
- Go 1.22がリリースされた後、新しいセマンティクスに依存して書かれたコードは、非常に古いサポート終了済みGoバージョンを使わない限り、従来のセマンティクスではコンパイルされない
Go 1.21でプレビューを実行する
- Go 1.21にはループスコープ変更のプレビューが含まれる
GOEXPERIMENT=loopvarを設定してコンパイルすると、go.modのgo行を無視して、すべてのループに新しいセマンティクスが適用される
- パッケージとすべての依存関係が新しいループセマンティクスでもテストに通るか確認するには、次のように実行する
GOEXPERIMENT=loopvar go test
- Go Playgroundでは、プログラムの先頭に
// GOEXPERIMENT=loopvarコメントを入れて新しいセマンティクスを試せる
- Googleの社内Goツールチェーンは2023年5月初旬から、すべてのビルドでこのモードを強制するようにパッチされ、その後4か月間プロダクションコードの問題報告はなかった
新しいセマンティクスが明らかにするテストのバグ
- 新しいループセマンティクスはプロダクションコードの問題は引き起こさなかったが、誤って通過していたテストを明らかにした
t.Parallelを使うサブテストの例では、Go 1.21はループ全体が終わるまで各サブテストを止めてから並列実行する
- ループが終わると
vは常に6なので、すべてのサブテストが6が偶数かどうかを確認して通過する
- 実際のテストケースには
1があるため、テストは失敗すべきである
- Go 1.21では
loopclosureアナライザーの精度が改善され、この問題を特定して報告できる
- Go Playgroundでの報告例: プログラム例
go vetがテストでこのような問題を報告した場合、それを修正することがGo 1.22への準備に役立つ
- 新しいセマンティクス適用時に特定のテスト失敗を引き起こすループを見つけるツールと例は、FAQにまとめられている
さらに読む
1件のコメント
Hacker News のコメント
もっと早い例もあるだろうが、60秒ほど検索して見つけたこの挙動に関する最古の警告は、30年以上前の1992年に投稿された comp.lang.lisp FAQ だった
DOTIMES、DOLIST、DOは反復変数を更新するときに束縛ではなく代入を使うため、例のようにlambdaがnをキャプチャすると、10個のクロージャがすべて同じ変数Nの値の上に作られる、と説明されている参照でキャプチャするなら、実際には予想どおりの挙動ではある
それでも一度挙動を学べば問題ではなくなるし、必要ならフォームを選んでマクロ展開し、実装方法を確認できる
C# 言語チームも C# 4.0 で軽量クロージャを導入した後に同じ問題に遭遇し、これがすぐに落とし穴だと明らかになった
ユーザーはほぼ常にループ変数を誤って使っており、C# 5.0 で互換性を破る変更を入れた
Eric Lippert がその観点から「なぜ」をうまく説明した記事を書いている: https://ericlippert.com/2009/11/12/closing-over-the-loop-var...
元の C# 5 発表記事は見つけにくかったが、2012年以降の Microsoft ドメイン上での複数回のブログ移転の中で消えていないことを願う
Python 2 から 3 に移るとき、文字列型の変更だけでも大騒ぎになったことを考えると、この変更が Python 4.0 より前に入るとは思えない
そして誰かは、Python がこういうものを直さないから悪いと言いながら、2003年に作ったスクリプトが動かないとまた Python を罵るのだろう
言語変更の提案が基本的に持つべき「まずは拒否」というハードルを越えるうえで、大きな役割を果たしたと思う
もう一つ大きく説得力があったのは、公開ソースコードベースをスキャンして、修正されるバグと新たに生まれるバグのバランスを見た結果だった
値渡しなので、呼び出し時点の変数の状態をキャプチャし、コードの曖昧さを減らしてくれる
変数を妙にキャプチャしようとすると、たとえば配列をマップに変換しようとして蓄積しているコレクションと、宣言された変数群とで挙動が異なることになる
Go はループカウンタにだけこの挙動を適用してバランスを取ろうとしているようだが、それでも一部の変数は奇妙に振る舞う
特に入力を直接スキャンしようとしてループ変数を複数定義する場合に何が起きるのか気になる
for(let)ループを導入したhttps://eli.thegreenplace.net/2019/go-internals-capturing-lo... がこの問題をより詳しく説明しているようだ
i := iというトリックが動く理由が、思っていたものとはまったく違っていたのが興味深い最初は、新しい
iが goroutine に渡されるので、エスケープ解析がこれをレキシカルスコープの外へエスケープするとマークし、そのためヒープに割り当てられ、反復ごとにヒープ割り当てが1つずつ発生して各 goroutine が固有のメモリ位置を参照するのだと思っていた実際には Go コンパイラには参照キャプチャと値キャプチャを選ぶヒューリスティックがあり、初期化後に更新されない値は値でキャプチャする条件がある
新しい
iはfor本文のスコープにあり、ループ自体が更新しないため、初期化後に更新されない値と判断され、ヒープ割り当てなしで値としてキャプチャするコードが生成される後者のほうが優れているのは分かるが、前者の方式がなぜ同時に起きないのか、Go に詳しい人から聞いてみたい
この変更は、現在の動作に依存しているプログラムを壊してしまうのではないか?
go.modでgo 1.22以上を宣言したモジュール内のパッケージにのみ適用されるファイル単位では
//go:build行を使って決定することもできるその約束は、Go 1 仕様で書かれたプログラムは仕様の存続期間中、変更なしにコンパイルされ続け、正しく実行されなければならず、いつか Go 2 仕様が出る可能性はあるが、それまでは Go 1.1 や Go 1.2 のようなポイントリリースでも、今日動作する Go プログラムは動作し続けるべきだと言っている
この設計のせいで意図しないバグを作った人の数は、修正によって影響を受ける人の数よりはるかに多いだろうと考えた
記憶では、Google のコードベースや GitHub 上のコードで、この変更が期待される動作を壊すケースはほとんどなかったという
影響を受けるコードベースがどれほど少ないかを確認し、
go.modのバージョン指定によって新しい動作を使うにはコードを能動的に修正する必要がある仕組みを作った後で、ようやく後方互換性を破る決定をしたのだhttps://twitter.com/go100and1/status/1690412229135601664
https://twitter.com/go100and1/status/1690587305806057472
https://twitter.com/go100and1/status/1690589791686119424
https://twitter.com/go100and1/status/1690591234715492352
https://twitter.com/go100and1/status/1690593184857145344
https://twitter.com/go100and1/status/1691456732151889920
大半は提案文書でまったく言及されていなかった
Python でもこの問題を経験したことはあるが、最近ではない
Python が変わったのか、自分が問題に気づくようになったのかははっきりしない
いまでも Python で問題になり得ることは、このコードだけでも十分に分かる:
funcs = [(lambda: x) for x in range(3)];funcs[0]()は2を出力するPython は以前はもっと悪く、リスト内包の外側のスコープまで共有していた
リスト内包やループの中でラムダを使うと、
xの現在値ではなく変数xへの参照をキャプチャするfuncs[0]()を呼び出す時点では、すでにxはrangeの最後の値である2に設定された後である望む動作を得るには、ラムダのデフォルト引数として
xを渡せばよい:funcs = [(lambda x=x: x) for x in range(3)]Go は少しだけ使ったことがあり、この変更が解決する一般的な問題は分かるが、より微妙な例である letsencrypt のケースや
"range c.informerMap"と"range alarms"の違いはよく理解できないfor k, v := range someMapでvはマップの値の型で、ループ全体で 1 つのバインディングがあり、反復ごとにコピーされるということなのか? そうなら問題は説明できるが、vはマップ内部を指す参照だろうと予想していた仕様の “For statements with range clause” をざっと読んでも答えが見つからなかったが、Go をほとんど触っていないので、見当違いの場所を見ていたようだ: https://go.dev/ref/spec#For_statements
追記: 答えはコードブロック形式の表にあった。バナーのように読み飛ばしてしまったらしい。
vが参照ではなくコピーされた値だとは驚き配列スロットへのポインタはサポートしているが、
for rangeは各スロットを指すポインタを渡すのではなくコピーするvの型はintである値であって、
intへのポインタではない気になるなら確認してもよい: https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
基本的にコンパイラが自動逆参照のために
go a.Monitor(b)を(&a).Monitor(b)に書き換えている「前方互換性の取り組みの結果として、Go 1.21 は
go 1.22以上を宣言したコードをコンパイルしようとしない。Go 1.20.8 と Go 1.19.13 のポイントリリースにも同じ効果を持つ特別な処理を入れたので、Go 1.22 がリリースされれば、新しいセマンティクスに依存して書かれたコードは、非常に古い未サポートの Go バージョンを使わない限り、決して従来のセマンティクスでコンパイルされない」という部分がどう動くのか気になるあるパッケージが 1.22 に固定していて、自分が 1.18 でコンパイルしたら、コンパイルされるのか、それとも 1.22 のコンパイラが必要だというエラーになるのか?
Go 1.21 で
go.modファイルのバージョン番号形式を変えたため、Go 1.18 でビルドしようとするとgo.mod:3: invalid go version '1.21.0': must match format 1.23のようなエラーが出るただしこれは
go mod initでモジュールを作った場合だけで、go.modに手動でgo 1.21と書けば、文句を言わずにビルドされるかなり素晴らしい機能だが、意外な動作だし、バイナリを受け取りに Google が管理するサーバーへ接続するという点で少しためらいがある
モジュールプロキシと並んで Go の中で最も評価が分かれる機能の一つで、Go が Google が出資するだけの財団によって管理されていたなら、ずっと安心できただろうと思う
編集: 考えてみると、これは依存関係が別のバージョンを宣言したときではなく、現在のモジュールが宣言したときの話なので、元の質問とは違う
だから Go 1.18 の使用は積極的に危険になる
Go 1.19 ではコンパイラエラーになるはず
いずれにせよ Go は古いリリースや標準ライブラリにセキュリティバグ修正を行わないので、そうしたバージョンを使うこと自体が危険だと思う
ただし Go 1.22 でコンパイルしても、あなたのコードは依然として Go 1.18 のセマンティクスを持つ
Go はある意味で非常に奇妙な言語だ
とても強い主張を持つ言語でありながら、同時にあまりに主張のない言語のようにも見える
c.informerMapを走査するコードとalarmsを走査するコードの違いが何なのかははっきりしないが、推測するなら、一方のループ変数はポインタで、もう一方は値なのかもしれないメソッド呼び出しがポインタレシーバを使うため、値の場合はコンパイラがレシーバへの参照を自動的に入れるのではないだろうか?
https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
違いは、一方では
informerがインターフェイスなので、メソッド呼び出しが即座にinformer.Runとして解決され、問題がないこともう一方では
aはAlarm構造体で値としてコピーされ、Monitorメソッドはポインタレシーバを受け取るそのためコンパイラは実質的に
go a.Monitor(b)をgo (&a).Monitor(b)に書き換え、これがループ変数への参照を作って問題を引き起こす2つ目は
aが結局alarmsの最後の要素の値だけを持つことになるため、記事で説明されている元の問題が発生するのだと推測する内部知識はそこまでだが、スライスはヒープ上の backing array を持つので、ポインタや参照がある程度絡んでいる
これを読んで大いに安心した
Go の最大の欠点の一つが修正されるのだ
foo, err := getFoo(); if err != nil ...の後にbar, err := getBar(); fmt.Println(bar)のように書くと、getBarのエラーチェックを見落とすスコープ規則のせいで、
if foo, err := getFoo(); err != nilパターンは入れ子が少し深くなるだけで手に負えなくなるまた、不正な状態を導入する。
getFooがエラーを返すとき、何を返すべきなのか? API をポインタ返しに変えてnilを返すのか、それとも無効な状態の部分的に生成されたオブジェクトを置くのか悩むことになる