プロンプトとコンテキスト
面接官から、ユーザープロファイルの取得、注文情報の取得、おすすめ情報の生成という3つの関連オペレーションを並列に実行するリクエストハンドラーが提示されます。クライアントの切断、クリティカルな子タスクの失敗、またはリクエスト全体のデッドライン超過が発生する可能性があります。システムは、リクエスト終了後に孤立した処理(オーファンワーク)を実行したままにしてはなりません。構造化並行性(Structured Concurrency)を説明し、特定のAPIに依存することなく、タスクグループ、コルーチンスコープ、または構造化タスクスコープに関連付けて説明してください。
この質問は、中級およびシニアのバックエンド、プラットフォーム、モバイル、クロス言語インフラストラクチャのロールに適しています。狙いは、ライフサイクルの推論(開始、終了、障害ポリシー、リソースの所有権)を評価することです。
面接官がテストしていること
面接官は、並行処理をグローバルなプールに投入してそのままリターンするのではなく、親タスクの一部として扱えているかを確認したいと考えています。優れた回答では、子タスクが自身のスコープを超えて生存しないこと、親のキャンセルが伝播すること、ビジネス価値に応じてフェイルファストまたは部分障害として処理できること、そしてジョインとクリーンアップが可視化された境界内で行われることを明確にします。
不十分な回答は、「async/await を並列に使う」と答えるだけです。優れた回答は、構造化並行性と分離された Future(detached futures)を明確に区別し、処理をいつ別の所有者を持つ永続キューに移すべきかを説明します。
最初に明確にすべき質問
- 3つの子タスクすべてが成功する必要がありますか? それともプロファイルと注文は必須で、おすすめ情報は任意ですか?
- デッドラインは厳格なリクエストデッドラインですか? それともサーバー側で別のソフトなバジェットを使って完了させることができますか?
- 子タスクが行うのはキャンセル可能なI/Oのみですか? それとも元に戻せない外部副作用を引き起こす可能性がありますか?
- クライアント切断後、処理を非同期通知やバックグラウンドジョブに移行してもよいですか?
- 障害発生時は、単一のエラーを返すべきですか? 部分的な結果ですか? それとも明示的にデグレード(縮退)されたレスポンスですか?
これらの回答によってスコープの境界が変わります。短時間のリクエスト処理は親スコープ内にとどまり、リクエスト後も継続する処理は明示的な所有権の移譲が必要になります。
30秒で答える要約
リクエストを親タスクとし、3つの並列読み取りを子タスクとして扱います。親は、子タスクが完了、失敗、またはキャンセルされてクリーンアップされた後にのみ終了します。プロファイルまたは注文の失敗は兄弟タスクをキャンセルしてリトライ可能なエラーを返し、おすすめ情報の失敗は明示的なデグレードレスポンスを返します。クライアントの切断やデッドラインはキャンセルを下流へ伝播させ、各子タスクはクリーンアップパスでコネクションやハンドルを閉じます。リクエスト後も処理を継続する必要がある場合は、バックグラウンドの Future を切り離すのではなく、永続キューに書き込んで新しいライフサイクルを開始します。フォールトインジェクションを用いて、キャンセル、タイムアウト、部分障害、リソースリークのメトリクスを検証します。
ステップ・バイ・ステップの回答
ステップ 1:タスクツリーを描く
リクエストハンドラーをルート(根)とします。プロファイル、注文、おすすめ情報は直接の子タスクになります。子タスクは親のデッドライン、トレーシングコンテキスト、キャンセルシグナルを継承します。親は待機、エラー集約、スコープ終了の所有権を持ちます。子タスクが所有権の移譲なしにグローバルなエグゼキューターへ処理を渡し、親が先にリターンすることはできません。
重要な不変条件は検証可能です。親が終了したとき、すべての子タスクは完了しているか、キャンセルされているか、あるいは明示的に記録された別の所有者に移譲されています。この不変条件がなければ、スレッドリーク、重複書き込み、原因不明のテールレイテンシーが潜在し続けることになります。
ステップ 2:障害および部分結果ポリシーを選択する
ビジネス価値に基づいて分類します。プロファイルと注文はチェックアウト画面に必須であるため、どちらかが失敗した場合は兄弟タスクをキャンセルし、リトライ可能なエラーを返します。おすすめ情報は付加価値であるため、失敗した場合はおすすめ情報なしのレスポンスを返し、機能低下(デグラデーション)を記録します。価値の低い障害でリクエスト全体を停止させてはならず、重要なデータの欠落を空のオブジェクトで偽装してもいけません。
言語に依存しない擬似コードでポリシーを表現できます。
within request_scope(deadline):
profile = child(fetch_profile)
orders = child(fetch_orders)
recommendations = child(fetch_recommendations)
wait(profile, orders)
if profile.failed or orders.failed:
cancel_all_children()
return retryable_error
return compose(profile, orders, recommendations.or_empty)ステップ 3:キャンセルをリソース境界まで到達させる
キャンセルは、タスクオブジェクト上の単なるブール値フラグではありません。ネットワーククライアント、データベースドライバ、ファイル読み込みがそのシグナルを検知しなければなりません。待機処理は中断可能である必要があり、リトライループは再試行の前にデッドラインを再確認する必要があります。承認された決済や送信済みメールなど、不可逆な外部副作用の場合、キャンセルは後続のステップを停止できますが、完了した副作用を取り消す(ロールバックする)ことはできません。
クライアントが切断されると、エントリーポイントがルートをキャンセルします。ルートはキャンセルを伝播させ、子タスクはそれぞれのクリーンアップパスでレスポンスボディ、コネクション、サブスクリプション、一時ファイルを閉じます。「クリーンアップ待ち」がリクエストのシャットダウンを無期限にブロックしないよう、クリーンアップ自体にも時間的猶予(バウンド)が必要です。
ステップ 4:タイムアウト、キャンセル、障害を分離する
タイムアウトはバジェット(許容時間)が枯渇したことを意味します。キャンセルは上流が結果を必要としなくなったことを意味します。障害はタスクを完了できなかったことを意味します。これらは同時に発生することもありますが、ログやユーザー向けの状態を1つの500エラーに統合してはなりません。原因、トリガー、タスクパスを個別に記録します。通常、クライアントの切断時にはリトライを行いません。依存関係のタイムアウト後の短いリトライは、残りバジェットが許す場合にのみ1回許可します。ビジネスエラーは明示的なデグラデーションポリシーを通じて処理します。
スコープ外で盲目的なリトライや sleep を実行してはなりません。それらは期限切れのバジェットを消費し、親がリターンした後も負荷を生成し続けます。
ステップ 5:構造が維持されていることを証明する
正常完了、クリティカルな子タスクの失敗、任意の子タスクの失敗、親のキャンセル、デッドライン超過、子タスク開始前のキャンセルをテストします。すべてのテストで、アクティブな子タスク数がゼロに戻ること、下流のコネクションが閉じること、トレースに親子関係が表示されること、副作用が重複しないこと、エラーの分類がユーザーに見える状態と一致していることを確認します。
アクティブなタスク数、キャンセル伝播レイテンシー、デッドラインタイムアウト率、子タスクの例外、クリーンアップ所要時間、キャンセル後も実行中のタスク数、リクエストあたりの最大ファンアウト数を追跡します。単に成功レスポンスが返るだけでは、構造化並行性が成立している証明にはなりません。
質の高い模範解答
「1つのリクエストを親タスクとして扱い、3つの読み取りを1つのデッドライン付きスコープに配置します。親が子タスクを生成し、待機し、閉じます。いかなる子タスクもそのスコープを超えて生存することはできません。プロファイルと注文はクリティカルな依存関係であるため、どちらかが失敗した場合は兄弟タスクをキャンセルしてリトライ可能なエラーを返します。おすすめ情報は任意であるため、失敗時には明示的な空のおすすめ状態を返し、デグラデーションを記録します。
クライアントの切断、親のタイムアウト、上流のキャンセルはタスクツリーの下流へと伝播します。各I/O呼び出しはキャンセル可能なインターフェースを使用し、リトライは残りバジェットを確認し、クリーンアップはコネクションやサブスクリプションを閉じます。外部副作用が発生した後に、それをロールバックできると偽ることはしません。リクエスト終了後も処理を継続する必要がある場合は、まず永続キューに書き込み、コンシューマーに新しいタスクツリーを作成させます。
クリティカルな障害、任意の障害、キャンセルの競合、クリーンアップのタイムアウトを注入(フォールトインジェクション)し、アクティブタスク、キャンセルレイテンシー、リーク、親子のトレースを観察します。重要なのはライフサイクルと所有権の設計であり、Java、Kotlin、Swiftなどの特定のAPIではありません。」
よくある失敗パターン
- 構造化並行性を単なる並列実行と同一視する → タスクを同時に開始することだけを説明し、ライフタイムを省略している → スコープ、ジョイン、キャンセル、クリーンアップの境界を示す。
- グローバルなエグゼキューターに投入してリターンする → 親が終了した後に処理がリクエストリソースにアクセスする可能性がある → リクエストスコープ内にとどめるか、永続キューへ明示的に移譲する。
- キャンセルを強制終了として扱う → 外部副作用がすでに完了している可能性があり、リソースが自動で解放されない場合がある → 協調的キャンセル、不可逆な処理、クリーンアップの所有権を明記する。
- すべての障害に対して空の結果を返す → 重要なデータの欠落が呼び出し元から隠蔽される → ビジネスの重要度に基づいてフェイルファストと部分結果のルールを定義する。
- 成功ケースのみをテストする → キャンセルの競合やオーファンワークが見過ごされる → 切断、デッドライン、兄弟タスクの失敗、重複キャンセルを注入し、アクティブな処理がゼロになることを確認する。
フォローアップの質問
フォローアップ 1:おすすめ情報の生成に数秒かかりますが、リクエストはすでに終了しています。どう対処しますか?
まず、ユーザーがその結果をまだ必要としているかを確認します。現在のページ専用であるなら、親とともにキャンセルします。ビジネスとして非同期生成を望むなら、入力と冪等性キーを永続化し、コンシューマーに新しいスコープを作成させます。そのコンシューマーが新しいデッドライン、リトライポリシー、アラートを管理します。終了したリクエストのコンテキストを流用することはできません。
フォローアップ 2:1つの子タスクが失敗した後、他の子タスクを最後まで実行させないのはなぜですか?
残りの処理に価値があり、バジェット内に収まるのであれば完了させても構いません。クリティカルなプロファイルデータが失敗した後に注文の検索を継続することは、通常コネクションと下流のキャパシティの浪費になります。一方で、独立したキャッシュ更新や監査ログの記録を完了させるのは妥当な場合があります。フェイルファストを一律に適用するのではなく、キャンセルの条件を明確に定義してください。
フォローアップ 3:キャンセルシグナルを送信しましたが、データベースクエリがまだ実行中です。どう処理しますか?
ドライバがキャンセルをサポートし、コネクションを返却するかどうかを確認します。サポートしていない場合は、独立したクエリタイムアウトを適用し、プールを隔離し、遅れて届いた結果を使用しないよう保護します。キャンセルからリソース解放までの時間を測定し、閾値を超えた場合はデグラデーション、隔離、またはアラートをトリガーします。キャンセル不可能なクエリがリクエストレベルのキャパシティを無期限に占有してはなりません。
フォローアップ 4:構造化並行性はすべてのスレッドプールやメッセージキューを置き換えるものですか?
いいえ。構造化並行性は、明確な親子関係を持ち、キャンセルとクリーンアップを共有する短命な処理に適しています。リクエストをまたぐ処理、スケジュールされた処理、永続的なリトライには、引き続きキューやワークフローが必要です。共有プールは実行リソースを提供できますが、投入元のスコープが「誰が待機し、誰がキャンセルし、誰が結果を所有するか」を定義しなければなりません。