Go 1.22以前は、言語仕様がループステートメントごとにループ変数を1回だけ割り当てていました。これは各反復で値が順次変更されつつ、同じメモリ位置が再利用されていたことを意味します。この変数を参照でキャプチャするクロージャーは、ループ内で起動されたゴルーチンでは一般的です。その結果、すべてのクロージャーが同一のメモリアドレスを共有しました。したがって、ループが完了した後、そのアドレスに割り当てられた最終値をすべてのクロージャーが観察しました。
Go 1.22では反復ごとのスコーピングが導入され、各反復が新しい変数を異なるメモリアドレスでインスタンス化します。これにより、クロージャーはその反復の特定の値をキャプチャすることができ、共有可能な可変位置を参照することはなくなります。この変更により、ループ変数のアドレスの同一性に依存しないコードの後方互換性を維持しつつ、最も一般的な同時実行の落とし穴の1つを排除しました。
データ処理サービスは、センサー読み取り値をワーカーゴルーチンにファンアウトして、ストレージ前に並列検証を行う必要がありました。
チームは最初に、慣例的なクロージャー構文を使用してファンアウトを実装しました:
readings := []SensorReading{{ID: 1}, {ID: 2}, {ID: 3}} for _, r := range readings { go func() { validate(r.ID) // 重大なバグ: すべてのゴルーチンがID 3を検証 }() }
デプロイ後、ログからすべてのワーカーが同じ最後のレコードを処理していることが明らかになり、以前のレコードは完全に無視され、データ損失を引き起こしました。
解決策1: 変数のシャドーイング。 このアプローチは、ループ本体内で新しい変数を導入し、反復変数をシャドーイングすることで、各反復のために異なるスタック割り当てを強制します。利点: キャプチャ問題が即座に解決され、関数シグネチャの変更が不要です。 欠点: レビューアにとって構文的に冗長に見える微妙なレキシカルトリックに頼っており、リファクタリング中に誤って取り除かれた場合、コンパイラーによる保護がありません。
解決策2: パラメータ渡し。 この方法では、値を引数としてクロージャーに明示的に渡すことで、評価が各反復で行われることを保証します。利点: 明確で、すべてのGoバージョンにおいてポータブルであり、データ依存関係を明示的かつ自己文書化します。 欠点: クロージャーをパラメータを受け入れるように再構築する必要があり、最小限だがゼロでない構文オーバーヘッドが追加されます。
解決策3: インフラストラクチャのアップグレード。 新しい反復ごとの変数意味論を活用するために、全フリートをGo 1.22+に移行します。 利点: 言語レベルで根本原因を排除し、よりクリーンな慣用コードを可能にします。 欠点: 協調的なインフラの変更が必要であり、古いツールチェーンにとどまる必要のあるレガシーコードベースには救済措置を提供しません。
チームは、即時デプロイのために解決策2を選択しました。この決定により、コードはすべてのコンパイラバージョンで正しく動作し、誤って削除される可能性のある微細なシャドーイングのトリックに依存しなくなりました。
実装後、各ゴルーチンは独自のセンサーIDを受け取り、パイプラインはすべてのレコードを正しく処理し、システムはその後のGo 1.22へのアップグレード中に安定していました。
Go 1.22+でfor-range反復変数のアドレスを取ることが、元のスライス要素の直接変更を許可しないのはなぜですか?
反復ごとの変数であっても、反復変数はスライス要素のコピーを保持しており、実際の要素そのものではありません。そのアドレスを取ると、この一時的なコピーへのポインタが得られるだけであり、基になる配列のエントリではありません。各反復の変数は異なる位置ですが、値のコピーを含んでいるため、*(&v)を変更すると一時的なコピーだけが影響を受け、反復が終了すると破棄されます。ソーススライスを変更するには、インデックス構文を使用する必要があります: for i := range slice { slice[i].Field = NewValue }。
Go 1.22の反復ごとのスコーピング変更は、プレ1.22の変数再利用モデルと比較してパフォーマンスオーバーヘッドや追加のヒープ割り当てを引き起こしますか?
いいえ。Goコンパイラは、クロージャーがヒープに逃げない場合、反復ごとの変数をスタックまたはレジスタに最適化します。この意味論的変更は、レキシカルスコーピングとポインタの同一性に影響を及ぼしますが、ループ自体の割り当て戦略や実行時パフォーマンスには影響を与えません。クロージャーなしのループは、変更前と変更後で同一のパフォーマンス特性を示します。
プレ1.22のGoにおける変数再利用の振る舞いは、伝統的な三項forループに対してfor-rangeループにどのように影響しましたか?
その振る舞いは、すべてのforループバリアントで同じでした。for i := 0; i < n; i++とfor _, v := range mの両方が、すべての反復にわたって反復変数のために同じメモリアドレスを再利用しました。候補者は、古典的なクロージャバグがrangeループに特有のものであると誤って仮定することがよくありますが、三項ループでインデックスiをキャプチャするクロージャーも同じ問題に苦しみ、期待される反復値ではなく最終的なiの値を印刷しました。Go 1.22は、すべてのループタイプに対してこの問題を一律に解決しました。