プロンプトとコンテキスト
非同期APIがユーザー、注文、レコメンデーションのデータを並行して読み込みます。必須タスクで障害が発生した場合は兄弟タスクをキャンセルする必要があり、クライアントの切断や全体のタイムアウトによって孤立したコルーチンが残ってはなりません。asyncio.TaskGroupを使用して実装を設計し、例外の集約、キャンセル処理、およびリソースのクリーンアップについて説明してください。
Pythonのドキュメントでは、TaskGroupは構造化並行性(Structured Concurrency)として説明されています。タスクはスコープを抜ける前に終了し、キャンセル以外の例外が発生すると残りのタスクがキャンセルされ、ExceptionGroupとして送出されます。この面接では、単にcreate_task()を並べることではなく、ライフサイクルの論理的思考が評価されます。
面接官が見ているポイント
親子タスクツリー、兄弟タスクのキャンセル、CancelledError、ExceptionGroup、およびasync withによる結合(join)の処理です。候補者はキャンセルをデータベース、HTTP、ファイル処理へと伝播させ、レスポンス後も存続させるべき処理を永続キューへと移行できる必要があります。
確認すべき質問
- 3つの読み込みはすべて必須ですか?それともレコメンデーションはグレースフルデグラデーション(機能縮退)が許容されますか?
- コネクション、カーソル、または一時ファイルを開くタスクはどれですか?
- 全体のタイムアウトは誰が設定し、呼び出し元のキャンセルはどのように届きますか?
- ど coarse なエラーの分類、リトライ、またはユーザー向けの安全なレスポンスが必要ですか?
- HTTPレスポンスの返却後も継続する必要があるジョブはどれですか?
30秒での回答
「async with TaskGroup()内で3つのタスクを作成し、スコープが完了するのを待ちます。キャンセル以外の例外は兄弟タスクをキャンセルし、例外グループを送出します。except*で既知のエラーを分類します。各タスクはfinally内でリソースを解放します。グループ全体を1つのasyncio.timeout()または呼び出し元からのキャンセルでラップします。リクエスト後も存続すべき処理は永続キューに投入します。」
ステップ・バイ・ステップの解決策
ステップ 1: タスクツリーと結果の契約(Contract)を定義する
各結果とその重要度を宣言します。ユーザーと注文は必須であり、レコメンデーションはオプションである場合があります。この選択によって、1つの例外でグループ全体をキャンセルするかどうかが決まります。すべてのタスクはリクエストスコープに属します。
async with asyncio.TaskGroup() as group:
user_task = group.create_task(load_user(user_id))
order_task = group.create_task(load_orders(user_id))
rec_task = group.create_task(load_recommendations(user_id))スコープを抜けることでタスクが結合(join)されます。ブロックの外側で未完了のタスクを読み取ることは、タスクの結合の代わりにはなりません。
ステップ 2: ExceptionGroupで障害を処理する
CancelledError以外の例外は兄弟タスクをキャンセルし、TaskGroupはすべてのタスクが終了した後にExceptionGroupを送出します。予期される依存関係のエラーにはexcept*を使用し、未知のエラーは外側のハンドラーのために保持します。
try:
async with asyncio.TaskGroup() as group:
user = group.create_task(load_user(user_id))
orders = group.create_task(load_orders(user_id))
except* RetryableDependencyError as errors:
record_dependency_failures(errors.exceptions)
raise ServiceUnavailable from errorsBaseExceptionをキャッチして黙ってキャンセルをもみ消してはなりません。
ステップ 3: 実際のI/Oにキャンセルを伝播させる
TaskGroupはPythonのタスクをキャンセルします。HTTP、データベース、ファイルドライバー側でもキャンセルやタイムアウトのサポートが必要です。各タスクはfinally内でコネクションを閉じ、セマフォを解放し、一時ファイルを削除し、コンシュームを停止します。
async def load_orders(user_id: str):
conn = await pool.acquire()
try:
return await conn.fetch("SELECT ...", user_id, timeout=1.5)
finally:
await pool.release(conn)ドライバーがクエリを中断できない場合は、タスクのキャンセルだけに頼るのではなく、ステートメントタイムアウト、分離されたコネクション、またはバウンドされたバックグラウンドジョブを使用してください。
ステップ 4: 単一の全体タイムアウトを設定し、外部キャンセルを区別する
すべての処理で1つの予算(時間枠)を共有するように、グループ全体をasyncio.timeout()でラップします。
try:
async with asyncio.timeout(2.0):
result = await aggregate(user_id)
except TimeoutError:
return degraded_response("deadline")
except asyncio.CancelledError:
raise外部からのキャンセルは上位へ伝播させ続ける必要があり、成功レスポンスにしてはなりません。タイムアウトとユーザーによるキャンセルは異なる原因として記録します。
ステップ 5: TaskGroupとgatherの違いを理解する
asyncio.gather()は通常、最初の例外をその待機元に伝播しますが、兄弟タスクは必ずしもキャンセルされません。すぐに制御を戻すと、それらが孤立(orphan)する可能性があります。TaskGroupはタスクのライフタイムをスコープにバインドし、失敗時に兄弟タスクをキャンセルします。
gather(return_exceptions=True)は明示的な部分障害契約には便利ですが、すべての結果を検査する必要があります。これは構造化されたクリーンアップの代替にはならず、グループ内で際限のないバックグラウンドタスクを作成してはなりません。
ステップ 6: 障害の順序とクリーンアップをテストする
ユーザー処理が最初に失敗するケース、レコメンデーションが最初に失敗するケース、同時失敗、呼び出し元のキャンセル、全体のタイムアウト、ドライバーのタイムアウト、およびfinallyが失敗するケースを注入します。兄弟タスクがキャンセルを受け取ること、リソースが返却されること、保留中のタスクが残らないこと、すべての例外が子タスクに関連付けられることをアサートします。
非公開のペイロードを含めずに、タスク名、リクエストID、実行時間、キャンセルの原因、例外タイプ、ダウンストリームの呼び出しをログに記録します。遅延I/Oテストを繰り返して競合状態を露呈させます。すべて成功するテストだけでは不十分です。
模範解答
「TaskGroup内で3つのタスクを作成し、ユーザーと注文を必須、レコメンデーションを機能縮退可能にします。スコープがタスクを結合(join)し、キャンセル以外の障害が発生した場合は兄弟タスクをキャンセルしてExceptionGroupを送出し、それをexcept*で分類します。各I/Oタスクにはタイムアウトを渡し、finallyでコネクションを解放します。」
「外側のタイムアウトが1つの共有予算を提供し、CancelledErrorが伝播します。レスポンス後の処理は永続キューに入れます。同時失敗、キャンセル、タイムアウト、遅いI/Oを注入し、孤立タスクやリソースリークがないことを検証します。」
よくある間違い
- 単なる
create_taskの後にreturnする → 孤立タスクが発生 → タスクをTaskGroupスコープ内に保持する。 CancelledErrorをもみ消す → 親が停止できなくなる → クリーンアップを行ってから再送出する。- 各タスクに個別のフルタイムアウトを与える → 全体のレイテンシが制御不能になる → 1つの予算を共有する。
ExceptionGroupを単一のエラーとして扱う → 並行処理のエビデンスが失われる →except*で分類する。- Pythonタスクのみをキャンセルする → データベースやHTTPの処理が継続してしまう → ドライバーのキャンセルやタイムアウトを使用する。
- あらゆるケースにgatherを使用する → 部分障害とクリーンアップが曖昧になる → デグラデーション契約を定義する。
フォローアップ質問と回答
フォローアップ 1: TaskGroupは基盤となるI/Oを即座に停止しますか?
いいえ。ドライバー側でキャンセルまたはステートメントタイムアウトをサポートしている必要があります。そうでない場合は、コネクションを分離するか、バウンドされたバックグラウンドジョブに処理を移動します。
フォローアップ 2: レコメンデーションが失敗した際、なぜグループ全体をキャンセルしないのですか?
ビジネス契約に基づきます。オプションのレコメンデーションはエラーをキャッチして空の結果に置き換えることができますが、セキュリティや課金に関わる重要な処理では例外を外に逃がして兄弟タスクをキャンセルさせるべきです。
フォローアップ 3: ExceptionGroupをAPIレスポンスにどのようにマッピングしますか?
既知の依存関係エラーを安全な503または縮退結果にマッピングし、子エラーを記録します。未知のエラーはグローバルハンドラーに任せます。内部スタックトレースをクライアントに返してはなりません。
フォローアップ 4: TaskGroupの外部に配置すべき処理はどれですか?
メール送信、インデックス作成、バッチ書き込みなど、レスポンス後も継続する必要がある処理は、キューに永続化してワーカーによってリトライされるべきです。リクエストスコープには、リクエストのライフタイムと同じ処理のみを含める必要があります。
フォローアップ 5: キャンセル中にfinallyブロックで例外が発生した場合はどうなりますか?
クリーンアップ処理は小さく観測可能に保ち、クリーンアップの失敗をコンテキストとして添付し、元のキャンセルやビジネス例外を保持して根本原因が上書きされないようにします。