GoProgrammingシニアGo開発者

Goのランタイムが余分なゴルーチンスタックメモリを回収するメカニズムを評価し、解放をトリガーする利用率のしきい値と解放された領域の最終的な運命を指定してください。

Hintsage AIアシスタントで面接を突破

質問への回答

質問の歴史

Go 1.3以前、ランタイムはセグメントスタックを使用しており、関数呼び出しの境界でリンクされたチャンクに分割されていました。この設計により、スタック境界が頻繁に越えられる場合、厳しい「ホットスプリット」のパフォーマンスの崖が発生しました。Go 1.3では、成長中により大きな単一の連続領域にコピーされる連続スタックに置き換えられました。ただし、初期の連続スタックの実装では、ヒープにメモリが戻されることはなく、初期化やバッチ処理中に一時的に深い呼び出しスタックを必要とするゴルーチンのために、恒久的なRSSの成長が引き起こされました。Go 1.5では、ガーベジコレクション周期中に未使用のスタックメモリを回収する自動スタック縮小が導入され、ゴルーチンスタックのメモリ管理ライフサイクルが完了しました。

問題

縮小メカニズムがない場合、深い再帰に一時的に入るゴルーチン(例:深くネストされたJSONドキュメントの処理や複雑な依存関係ツリーのトラバースを行う)のピークスタックアロケーションは、アイドルイベントループに戻った後も無期限に保持されます。これにより、特にワーカープールを使用する長時間実行されるアプリケーションにおいてメモリの膨張が引き起こされます。高スタックタスクとアイドル状態の間を交互に行うゴルーチンが多いためです。課題は、スタックが真に過小利用されているタイミングを安全に特定し、進行中の計算やスタックアロケートされたポインタを破損することなく、アクティブなフレームをより小さなメモリ領域に移動することです。

解決策

Goランタイムは、ルートセットをスキャンするGCマークフェーズ中にスタックを縮小します。各ゴルーチンのスタック使用量を調べ、高水準の利用部分が現在のスタックサイズの4分の1(25%)を下回る場合、ランタイムは現在のスタックの半分のサイズの新しいスタックを割り当てます(ただし、最小2KBより小さくはなりません)。ランタイムは、ターゲットゴルーチンを安全なポイントで非同期的に停止し、ライブスタックフレームを新しい小さな領域にコピーし、コンパイラ生成のポインタマップを使用してスタックアドレスを参照するすべての内部ポインタを更新し、古いスタックメモリをランタイムのmheapアロケータに返します。

生活からの状況

我々は、高スループットのログ処理サービスを運営しており、各ゴルーチンは潜在的に深くネストされたJSONペイロードの解析を処理していました(不正な入力攻撃の際には最大10,000レベル深くなることもありました)。処理後、これらのゴルーチンは新しい接続を待つためにsync.Poolに戻りました。サービスのRSSメモリは、プールされたゴルーチンの数に比例して線形に増加し、アイドル期間中ですらメモリが解放されることはなく、最終的に4GBの制限のあるコンテナでOOMキルを引き起こしましたが、実際の作業セットはわずか200MBでした。

処理されたリクエストの設定された数の後にプールされたゴルーチンを強制的に終了し、新しい置き換えを生成することを検討しました。これにより、スタックメモリの解放が保証されるため、新しいゴルーチンは最小2KBのスタックで始まります。ただし、このアプローチは、恒常的なゴルーチンの生成と破棄による重要なCPUオーバーヘッドを引き起こし、TCP接続プールの最適化を乱し、キャッシュコールドスタートによる高いレイテンシーテイルラティンシーを引き起こしました。

debug.SetMaxStackを使用してスタック成長にハードリミットを実装すると、深い再帰イベント中の過剰なアロケーションを防ぐことができます。この方法はOOMから保護しましたが、正当ではあるが深い解析タスクがruntime: goroutine stack exceeds 1000000000-byte limitというパニックを引き起こすことになりました。これにより、顧客データが失われ、信頼性SLAを違反するサービスエラーが発生し、プロダクションには受け入れられない結果となりました。

我々は、30秒ごとに**runtime.GC()を呼び出し、その後にdebug.FreeOSMemory()**を実行してスタックスキャンと縮小を強制することを評価しました。これはRSSを成功裏に削減しましたが、毎回5-10msのストップ・ザ・ワールドのポーズを導入することになり、API層のp99レイテンシ要件<2msを違反しました。また、強制フルコレクションによるCPU利用率が15%増加しました。

最終的に、Goのネイティブなスタック縮小メカニズムに依存し、Go 1.20+を実行し、GOGCを設定することで、手動の介入なしにスタック縮小の機会をより頻繁にトリガーしました(100の代わりに50に設定)。これにより、自然な縮小が頻繁に発生し、メモリを制約できるようになりました。

サービスのRSSは、負荷がかかると約800MBで安定し、以前の3.8GBの上限から大幅に減少しました。ゴルーチンスタックプロファイルは、プールされたワーカーの95%がリクエスト間で最小の2KBのスタックサイズを維持し、アクティブな解析中のみスパイクが発生することを示しました。OOMキルは完全に停止し、p99レイテンシは手動のGCポーズやゴルーチンの回転を避けたため、1.5ms未満のままでした。

候補者が見逃しがちなこと

関数が返ったときにスタック縮小は即座に発生し、スタックポインタが減少しますか?

いいえ、ランタイムはスタックポインタの減少をリアルタイムで監視して即時解放をトリガーしません。縮小は、スケジューラーがすべてのゴルーチンスタックをスキャンするガーベジコレクションマークフェーズ中にのみ行われます。ランタイムは、最後のGCからのスタック使用の高水準をチェックします。この高水準が現在の物理アロケーションの25%未満である場合、初めて縮小ロジックが実行されます。この怠惰な評価は、ワールドがマーク中にすでに停止している期間中、すべてのゴルーチンにわたってスタックをコピーするコストを分散させますが、実際のコピーは個々のゴルーチンを停止する必要があります。

正確な縮小比率と最小サイズは何ですか?ランタイムはメモリをOSに戻すことがありますか?

スタックが縮小に適格な場合、ランタイムは現在のスタックの半分のサイズの新しいスタックを割り当てます。この幾何学的な削減は、閾値を若干上回ったり下回ったりするゴルーチンが絶えず成長したり縮んだりすることを防ぎます。新しいサイズは、プラットフォームの最小スタックサイズ(通常、64ビットシステムで2KB)で下限が設定されています。古いスタックからのメモリは、ランタイムのmheapに返され、直接OSには戻されません。OSは、スカベンジャーがヒープがアイドルで目標を超過していると判断するか、**debug.FreeOSMemory()**が呼び出されたときにのみこの物理メモリを回収します。

スタック縮小中にゴルーチンは停止し、ポインタはどのように更新されますか?

はい、縮小にはターゲットゴルーチンを安全なポイントで停止する必要があります。これはスタック成長と似ています。ランタイムは、ライブフレームを新しいメモリ位置にコピーし、スタックアロケート変数を参照するすべてのポインタを更新しなければなりません。コンパイラが、各フレームのどの単語がポインタであるかを特定するポインタマップを生成します。縮小中に、ランタイムはこれらのマップを使用して内部ポインタを見つけ、新しいスタックアドレスを指すように調整します。この操作は同時には行われず、コピー中にゴルーチンは実行できず、他のゴルーチンは引き続き実行されます。