Go 1.14以前、コンパイラは各defer文に対してヒープ上に_defer構造体を割り当て、それを各ゴルーチンのリンクリストにリンクさせていました。これにより、重大なGC圧力がかかり、深くネストされたdeferに対してO(n)のオーバーヘッドが発生していました。
Go 1.14ではスタック割り当てのdeferが導入され、コンパイラは逃避分析に基づいて_defer構造体を関数のスタックフレームに直接配置できるようになりました。後のバージョンではオープンコーディングdefer(Go 1.17+)が追加され、コンパイラはランタイムコールを使うのではなく、関数のエピローグにクリーニアップコードを直接挿入します。
パニック回復中、ランタイムはスタックをフレームごとにアンワインドします。アクティブフレームに見つかったスタック割り当てのdeferを実行し、その後リンクリストからの残りのヒープ割り当てのdeferを実行します。このハイブリッドアプローチは、一般的なケースでの割り当てコストを排除しながら、厳密なLIFO順序を保持します。
Goで書かれた高頻度取引APIラッパーは、市場の変動中に200ミリ秒のGCポーズを経験していました。
チームはこの問題を過剰なヒープ割り当てに追跡しました。各HTTPリクエストハンドラーは、tx.Rollback()や接続のクリーニングのために複数のdefer文を使用していました。負荷の下で、これにより毎秒何百万もの_defer構造体が生成され、頻繁なガベージコレクションサイクルがトリガーされました。
解決策A:手動リソース管理。チームはすべてのdefer呼び出しを削除し、すべての戻りポイントで明示的なClose()とRollback()を使用することを検討しました。利点:ゼロの割り当てオーバーヘッドと予測可能なパフォーマンス。欠点:コードが脆弱になり、エラーが発生しやすくなり、数十の出口パスにわたってクリーニングロジックが重複しました。
解決策B:オブジェクトプーリング。彼らはデータベーストランザクションオブジェクト自体をプールすることを試みました。利点:ユーザーコードの割り当てが減少しました。欠点:これは_defer構造体の割り当てには対処せず、これらはランタイム内部であり、ユーザーコードによってプーリングできません。
解決策C:コンパイラのアップグレードとリファクタリング。チームはGo 1.13から1.18にアップグレードし、ヒープに逃避する変数をキャプチャしないようにクロージャをリファクタリングしました。利点:自動スタック割り当てと大部分のケースでのランタイムコストゼロのdeferのオープンコーディング。欠点:パニック回復動作が正しいままであることを確認するために広範な回帰テストが必要でした。
彼らは解決策Cを選択しました。デプロイ後、GCのポーズ時間はサブミリ秒に短縮され、リクエストのスループットは40%増加しましたが、ビジネスロジックには変更はありませんでした。
名前付き戻りパラメータを修正する関数を遅延させることが最終的に返される値に影響を与えるのはなぜか、またこのパターンが名前なしの戻りで失敗するのはいつか?
Go関数が名前付き戻り値(例:func f() (err error))を使用する場合、遅延関数はその戻りパラメータの実際のスタックスロットを閉じます。その名前への任意の代入は、呼び出し元に返される値を修正します。名前なしの戻りでは、戻り値は遅延関数が実行される前に一時レジスタまたはスタック位置にコピーされ、defer内での修正は呼び出し元に見えなくなります。候補者は、deferが関数の実際の終了の瞬間に名前付き結果の最終値を見ることを見落としがちです。登録の瞬間ではありません。
緊密なループ内の遅延関数が古いGoバージョンでO(n²)のパフォーマンス特性を示す原因は何か、スタック割り当てがこのコストを完全に排除しないのはなぜか?
Go 1.14以前のバージョンでは、forループ内にdeferを置くと、各反復ごとに新しいヒープオブジェクトが割り当てられ、それがリンクリストに追加されました。これにより、リストが反復の線形で増加するため、二次的な複雑さが生じました。Go 1.14以降は、これらをスタックに割り当てますが、ランタイムは関数終了時に逆順でこれらのdeferをアンワインドして実行する必要があります。関数がnの操作を遅延させる場合、終了パスはそれらを処理するのにO(n)の時間がかかります。候補者は、スタック割り当てがあってもループ内での遅延はアンチパターンであり、手動クリーンアップが関数スコープでのO(n)の集約の代わりにO(1)のオーバーヘッドを提供することを見落としがちです。
パニック回復と遅延関数の相互作用は、遅延呼び出しがそれ自体がパニックされた場合に再開されないことをどのように防ぎ、その結果は順次実行とどのように異なるのか?
Go関数がパニックした場合、ランタイムはスタックをアンワインドし、遅延関数を順次呼び出します。遅延関数が対応するrecover()なしでパニックした場合、その新しいパニックは元のパニック値に置き換わります。重要なのは、遅延関数からパニックが広がった場合、ランタイムはその特定のフレーム内の残りのdeferを実行するのを停止し、上に向かってアンワインドを続けることです。候補者は、deferがトランザクションでないことを見落としがちであり、後続のdeferがパニックした場合に効果を元に戻さず、defer内のパニックがそのフレームのdeferチェーンの残りを中止し、後のdeferが重要なクリーンアップを行う予定だった場合にリソースが漏れる可能性があることも見落とします。