1 ポイント 投稿者 GN⁺ 2023-09-20 | 1件のコメント | WhatsAppで共有
  • Go 1.22は、forループ変数をループ全体ではなく反復ごとのスコープに変更し、クロージャが同じ変数を誤ってキャプチャするというGoの代表的なミスを減らそうとしている
  • 従来のセマンティクスでは、goroutineがなくても、反復後に実行される関数が同じviを参照し、最後の値だけを見たり、テストが誤って通過したりすることがある
  • go vetgoplsloopclosureアナライザーは確実なケースだけを検出するため見逃しが発生し、より積極的なチェッカーは誤検出のため不要なx:= xコードを増やす可能性がある
  • 新しいセマンティクスは、go.modgo 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の巡回中にkkCopy := kでコピーしていたが、modelToAuthzPB(&v)が結果生成の過程でvのフィールドポインタを使っていたため、vも別途コピーする必要があった
    • 変数キャプチャが複数の関数にまたがっていたため、問題に気づきにくかった
  • 静的解析ツールは、変数が反復後まで生き残るかを判断するのが難しく、誤検出見逃しの間で妥協せざるを得ない
    • go vetgoplsloopclosureアナライザーは、確実な問題だけを報告することで見逃しを受け入れている
    • より積極的なチェッカーは、正しいコードまで誤ったコードとして指摘する可能性がある
  • オープンソースのGoコードでx := x行を追加したコミットを見ると、実際のバグ修正だけでなく不要な変更も多く混ざっていた
    • 開発者がチェッカーを満足させるために不要なコードを追加する状況があった
    • informer := informera := aのような2つのdiffのうち、一方だけがバグ修正で、もう一方は不要な変更だったが、型や関数の情報がなければ区別は難しい

Go 1.22の新しいループセマンティクス

  • Go 1.22では、forループ変数が反復ごとに別スコープを持つように変更される予定である
  • 先の例はもはやバグのあるGoプログラムではなくなり、このようなミスによって生じるプロダクション上の問題や、不正確な検査ツールの必要性も減る
  • 後方互換性のため、新しいセマンティクスはgo.modgo 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.modgo行を無視して、すべてのループに新しいセマンティクスが適用される
  • パッケージとすべての依存関係が新しいループセマンティクスでもテストに通るか確認するには、次のように実行する
GOEXPERIMENT=loopvar go test
  • Go Playgroundでは、プログラムの先頭に// GOEXPERIMENT=loopvarコメントを入れて新しいセマンティクスを試せる
    • サンプルプログラム: Go Playgroundの例
    • このコメントはGo Playgroundでのみ適用される
  • 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件のコメント

 
GN⁺ 2023-09-20
Hacker News のコメント
  • もっと早い例もあるだろうが、60秒ほど検索して見つけたこの挙動に関する最古の警告は、30年以上前の1992年に投稿された comp.lang.lisp FAQ だった
    DOTIMESDOLISTDO は反復変数を更新するときに束縛ではなく代入を使うため、例のように lambdan をキャプチャすると、10個のクロージャがすべて同じ変数 N の値の上に作られる、と説明されている

    • D にも同じ問題がある: https://issues.dlang.org/show_bug.cgi?id=2043
      参照でキャプチャするなら、実際には予想どおりの挙動ではある
    • 標準では、このようなループが値を変更するのか再束縛するのかは明記されていないので、変数をキャプチャするなら再束縛はしないと仮定すべき
      それでも一度挙動を学べば問題ではなくなるし、必要ならフォームを選んでマクロ展開し、実装方法を確認できる
  • 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 も何年にもわたって同じ機能要望を何度も受けてきたが、いつも「大きな利点は少なく、既存コードを壊す」という答えが返ってきた: https://discuss.python.org/t/make-lambdas-proper-closures/10...
      Python 2 から 3 に移るとき、文字列型の変更だけでも大騒ぎになったことを考えると、この変更が Python 4.0 より前に入るとは思えない
      そして誰かは、Python がこういうものを直さないから悪いと言いながら、2003年に作ったスクリプトが動かないとまた Python を罵るのだろう
    • C# チームの jaredpar が、この Go 提案の GitHub 議論に最初のコメントを付けている: https://github.com/golang/go/discussions/56010
      言語変更の提案が基本的に持つべき「まずは拒否」というハードルを越えるうえで、大きな役割を果たしたと思う
      もう一つ大きく説得力があったのは、公開ソースコードベースをスキャンして、修正されるバグと新たに生まれるバグのバランスを見た結果だった
    • Java にも匿名クラスでこの問題があり、通常は関数オブジェクトを導入して解決する
      値渡しなので、呼び出し時点の変数の状態をキャプチャし、コードの曖昧さを減らしてくれる
      変数を妙にキャプチャしようとすると、たとえば配列をマップに変換しようとして蓄積しているコレクションと、宣言された変数群とで挙動が異なることになる
      Go はループカウンタにだけこの挙動を適用してバランスを取ろうとしているようだが、それでも一部の変数は奇妙に振る舞う
      特に入力を直接スキャンしようとしてループ変数を複数定義する場合に何が起きるのか気になる
    • JavaScript にも同じ問題があり、for(let) ループを導入した
    • Go らしく、以前の言語から学ばずにこの挙動を無視し、後になってまた直そうとする流れだ
  • https://eli.thegreenplace.net/2019/go-internals-capturing-lo... がこの問題をより詳しく説明しているようだ

    • 以前の i := i というトリックが動く理由が、思っていたものとはまったく違っていたのが興味深い
      最初は、新しい i が goroutine に渡されるので、エスケープ解析がこれをレキシカルスコープの外へエスケープするとマークし、そのためヒープに割り当てられ、反復ごとにヒープ割り当てが1つずつ発生して各 goroutine が固有のメモリ位置を参照するのだと思っていた
      実際には Go コンパイラには参照キャプチャと値キャプチャを選ぶヒューリスティックがあり、初期化後に更新されない値は値でキャプチャする条件がある
      新しい ifor 本文のスコープにあり、ループ自体が更新しないため、初期化後に更新されない値と判断され、ヒープ割り当てなしで値としてキャプチャするコードが生成される
      後者のほうが優れているのは分かるが、前者の方式がなぜ同時に起きないのか、Go に詳しい人から聞いてみたい
  • この変更は、現在の動作に依存しているプログラムを壊してしまうのではないか?

    • 既存コードとの後方互換性を保証するため、新しい意味論は go.modgo 1.22 以上を宣言したモジュール内のパッケージにのみ適用される
      ファイル単位では //go:build 行を使って決定することもできる
    • なぜダウンボートされているのか分からないが、実際にはGo 1 互換性の約束を破る変更であることは確か
      その約束は、Go 1 仕様で書かれたプログラムは仕様の存続期間中、変更なしにコンパイルされ続け、正しく実行されなければならず、いつか Go 2 仕様が出る可能性はあるが、それまでは Go 1.1 や Go 1.2 のようなポイントリリースでも、今日動作する Go プログラムは動作し続けるべきだと言っている
    • Go 1.21 の準備過程で非常に大規模な 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 は以前はもっと悪く、リスト内包の外側のスコープまで共有していた
    • この動作は Python クロージャの遅延バインディングによるもの
      リスト内包やループの中でラムダを使うと、x の現在値ではなく変数 x への参照をキャプチャする
      funcs[0]() を呼び出す時点では、すでに xrange の最後の値である 2 に設定された後である
      望む動作を得るには、ラムダのデフォルト引数として x を渡せばよい: funcs = [(lambda x=x: x) for x in range(3)]
  • Go は少しだけ使ったことがあり、この変更が解決する一般的な問題は分かるが、より微妙な例である letsencrypt のケースや "range c.informerMap""range alarms" の違いはよく理解できない
    for k, v := range someMapv はマップの値の型で、ループ全体で 1 つのバインディングがあり、反復ごとにコピーされるということなのか? そうなら問題は説明できるが、v はマップ内部を指す参照だろうと予想していた
    仕様の “For statements with range clause” をざっと読んでも答えが見つからなかったが、Go をほとんど触っていないので、見当違いの場所を見ていたようだ: https://go.dev/ref/spec#For_statements
    追記: 答えはコードブロック形式の表にあった。バナーのように読み飛ばしてしまったらしい。v が参照ではなくコピーされた値だとは驚き

    • Go はマップのキーや値へのポインタをサポートしていない
      配列スロットへのポインタはサポートしているが、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 と書けば、文句を言わずにビルドされる
    • 興味深いことに、Go 1.21 でモジュールがより高い Go バージョンを宣言すると、デフォルトの動作はより新しいツールチェーンを取得して代わりに使うこと: https://go.dev/blog/toolchain
      かなり素晴らしい機能だが、意外な動作だし、バイナリを受け取りに Google が管理するサーバーへ接続するという点で少しためらいがある
      モジュールプロキシと並んで Go の中で最も評価が分かれる機能の一つで、Go が Google が出資するだけの財団によって管理されていたなら、ずっと安心できただろうと思う
      編集: 考えてみると、これは依存関係が別のバージョンを宣言したときではなく、現在のモジュールが宣言したときの話なので、元の質問とは違う
    • 私の理解では、Go 1.18 では 1.22 モジュールが依存関係として入ってきてもコンパイルされ、この機能に依存しているなら誤ったロジックを作り得る
      だから Go 1.18 の使用は積極的に危険になる
      Go 1.19 ではコンパイラエラーになるはず
      いずれにせよ Go は古いリリースや標準ライブラリにセキュリティバグ修正を行わないので、そうしたバージョンを使うこと自体が危険だと思う
    • コンパイルエラーになるべき
      ただし Go 1.22 でコンパイルしても、あなたのコードは依然として Go 1.18 のセマンティクスを持つ
  • Go はある意味で非常に奇妙な言語だ
    とても強い主張を持つ言語でありながら、同時にあまりに主張のない言語のようにも見える

  • c.informerMap を走査するコードと alarms を走査するコードの違いが何なのかははっきりしないが、推測するなら、一方のループ変数はポインタで、もう一方は値なのかもしれない
    メソッド呼び出しがポインタレシーバを使うため、値の場合はコンパイラがレシーバへの参照を自動的に入れるのではないだろうか?

    • GitHub コード検索で、このコード断片が含まれている元のコードを見つけた
      https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
      https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
      違いは、一方では informer がインターフェイスなので、メソッド呼び出しが即座に informer.Run として解決され、問題がないこと
      もう一方では aAlarm 構造体で値としてコピーされ、Monitor メソッドはポインタレシーバを受け取る
      そのためコンパイラは実質的に go a.Monitor(b)go (&a).Monitor(b) に書き換え、これがループ変数への参照を作って問題を引き起こす
    • Go でマップを走査すると常に値がコピーされるので、最初のコードは期待通りに動作するように見える
      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 を返すのか、それとも無効な状態の部分的に生成されたオブジェクトを置くのか悩むことになる
    • 次はインターフェイスの nil チェックを直せばいい