context.Contextは、階層的なツリーを通じてキャンセルを伝播させ、各派生ノードが埋め込まれた cancelCtx や valueCtx 構造体を介して親への参照を維持します。このツリー構造は双方向のトラッキングを可能にします。親はミューテックスで保護されたマップを通じて子を知り、子は直接ポインタ参照を通じて親を知ります。キャンセルが発生したとき、この設計により、グローバルな調整なしにルートからリーフへの即時遍歴が可能になります。
親ノードで cancel() が呼び出されると、親の children マップを保護するためにミューテックスを獲得し、すべての登録された子コンテキストを反復し、それぞれの cancel クロージャを再帰的に呼び出します。各子の cancel 関数は、専用の done チャンネルを閉じ(キャンセルされないコンテキストに最適化するために sync.Once を介して遅延的に割り当てられ)、ガベージコレクションを妨げる参照を取り除くために親の children マップから自らを削除します。このメカニズムにより、キャンセル信号はサブツリー全体を即座に通過し、リソースのリークを回避します。
タイムアウトに基づくキャンセルのために、timerCtxは、締切が切れると cancel クロージャを自動的にトリガーする time.Timer を埋め込んでいます。重要なことに、親がタイマーが発火する前にキャンセルすると、子の cancel 関数は Stop() を介してタイマーを明示的に停止し、必要な場合はチャネルを排出して、既にコンテキストがキャンセルされた後にタイマーのゴルーチンがランタイムに残り、リソースを消費することを防ぎます。
高スループットの Go マイクロサービスがユーザーリクエストを処理し、3つの下流サービス(プライマリ PostgreSQL データベース、Redis キャッシュ、及びサードパーティの REST API)にファンアウトすることを考えてください。各リクエストは、すべてのソースに対してクエリを実行し、応答を集約しなければなりません。p99 レイテンシは500ミリ秒未満に予算編成されています。このサービスは、何千もの同時接続を処理しており、リソース管理が安定性のために重要です。
問題の説明:
過負荷の下で、クライアントはリクエストを送信した後に頻繁に切断(タイムアウトまたは接続の切断)しますが、goroutinesはデータベースに対して完全なクエリを処理し続け、遅い外部APIを待っており、接続プールとCPUを消耗させてしまいますが、結果は無価値です。手動キャンセルは、数十の関数呼び出しを通じてブールフラグをスレッド化する必要があり、これは脆弱でエラーが発生しやすいです。さらに、適切な伝播がない場合、これらの放棄されたリクエストを処理するゴルーチンは無限に蓄積され、最終的にはホストサーバー上で OOM(Out Of Memory)状態やファイルディスクリプタの枯渇を引き起こす可能性があります。
検討された異なる解決策:
原始的なフラグによる手動伝播: 各関数シグネチャを通じて atomic.Bool ポインタを渡し、ループ内で定期的にチェックすることを検討しました。このアプローチは、抽象化のオーバーヘッドがなく、キャンセルポイントに対する明示的な制御を提供します。しかし、ブロッキングシステムコール(例えば、TCP の読み取り)を中断することはできず、すべてのライブラリ関数に侵襲的なコード変更が必要で、タイムアウトや締切の標準化も提供されません。
明示的なキルチャネルによるゴルーチン農業: 各下流操作を別々の goroutine で起動し、カスタムクローズチャネルで select ブロックを使用することで、キャンセルが要求されたときに早期に返すことが許可されます。このアプローチは、ノンブロッキングキャンセルポイントを提供し、操作ごとのモジュラーなタイムアウト処理を可能にします。ただし、n が操作の数である場合、リクエストごとに O(n) のゴルーチンが作成され、かなりのスケジューリングオーバーヘッドが発生し、チャネルを受け入れないかキャンセル状態を確認しないサードパーティライブラリの中でキャンセルを強制することはできません。
標準的なコンテキストツリーの伝播: http.Request.Context() をルートとして利用し、各下流呼び出しに対して context.WithTimeout を介して子コンテキストを派生させることにより、標準ライブラリでネイティブなキャンセルサポートを提供します。この方法は、全体のコールスタックを通じて締切を自動的に伝播し、操作ごとのゴルーチンオーバーヘッドがなく、タイマーのクリーンアップを自動的に処理します。しかし、タイマーリソースが漏れないように、必ず WithTimeout によって返されたキャンセル関数を呼び出すなど、適切なAPI使用を厳守する必要があります。
選ばれた解決策と結果:
我々は標準コンテキストツリーの伝播を選びました。ここで、各HTTPハンドラは30秒のタイムアウトを持つリクエストスコープのコンテキストを派生させ、個々のデータベースクエリは context.WithTimeout(reqCtx, 2*time.Second) を使用してより厳格なサブ締切を強制します。クライアントが切断すると、HTTP サーバーはルートコンテキストをキャンセルし、ツリーを横断して sql ドライバのネットワーク呼び出しを即座にブロック解除して接続を解放します。10kの同時リクエストと30%のクライアントドロップでの負荷テストの下で、接続プールの枯渇イベントは95%減少し、アクティブリクエストに対する p99 レイテンシはリソース contention の減少により大幅に改善されました。
キャンセルされた子コンテキストがメモリリークを防ぐために自らを親の children マップから明示的に削除しなければならないのはなぜですか?
多くの人は、親が自己が破棄されるまで子を保持すると思っています。実際、cancelCtx.cancel() が実行されると(親の伝播またはローカルタイムアウトによる)、それは親のミューテックスを取得し、children マップから自らを削除します。この削除が行われなければ、長生きする親コンテキスト(バックグラウンドサーバーコンテキストのような)は、作成されたすべての一時的リクエストコンテキストのエントリを蓄積し、完了したリクエストメモリのガベージコレクションを妨げ、無限のヒープ成長を引き起こす可能性があります。
context.WithValue は、O(k) のルックアップ時間を維持しつつ O(1) の空間を各キーごとにどのように実現しているのか、またなぜマップを使用しないのか?
候補者はしばしば、各 WithValue 呼び出しでマップをコピーする(これはマップのサイズに対して O(n) になる)か、グローバルな同期されたマップを使用することを提案します(並行性の問題)。実際の実装はリンクリストを使用します:各 valueCtx はキー、値、親ポインタを含みます。Value() はキーを比較しながら上方に遍歴します。コンテキストツリーは通常5〜10レベル(リクエスト → ハンドラ → サービス → DB → tx)より深くないため、実質的に定数時間です。コンテキストごとにマップを使用するには、コピー(高価)もしくは変更可能性(並行読み取りに安全でない)が必要です。
context.Contextインターフェース変数に nil を格納することの特定の危険性は何か、またなぜ context.Background() が nil の代わりに非 nil の空構造体を返すのか?
var c context.Context = nil は有効ですが、キャンセル可能なコンテキストを期待する関数に渡すと、nilインターフェースでメソッドを呼び出すとパニックを引き起こします。Background() はシングルトン backgroundCtx{}(インターフェースを実装する非 nil の空構造体)を返し、メソッド呼び出しが常に成功することを確保し、コンテキストツリーの安定したルートを提供します。これにより、「nilインターフェース対 nil具象」の混乱(型付きnilポインタが != nil チェックを満たすが、メソッド呼び出しでパニックを引き起こす)を回避でき、コンテキスト値は決してnilにならず、親ポインタのみが論理的にnilになる可能性があります。