代表的な面接トピック

コーディング面接:Rust Tokioのコードをキャンセル安全(cancellation-safe)にするには?

コーディング難しい
Offer.cc 編集チーム公開日 更新日

質問

Tokioのselectがタイムアウト、キャンセレーショントークン、I/Oを待機するとき、あるfutureがキャンセル安全(cancellation-safe)であるかどうかをどのように判断しますか?一部完了した処理がキャンセルされた場合、どのように復旧しますか?

プロンプトとコンテキスト

Tokioの select! がタイムアウト、キャンセレーショントークン、I/Oを待機するとき、あるfutureがキャンセル安全(cancellation-safe)であるかどうかをどのように判断しますか?一部完了した処理がキャンセルされた場合、どのように復旧しますか?

これはRust、バックエンド、インフラストラクチャ、および非同期サービスの職種に適した設問です。Tokioではキャンセルをfutureのドロップ(破棄)として扱います。選択された select! のブランチは継続しますが、他のブランチは任意の .await でドロップされる可能性があります。重要な違いは、待機を停止することと外部の副作用を取り消すことの違いであり、安全に再開できるステートマシンを備えているかどうかにあります。

面接官がテストしていること

  • futureのドロップが、基盤となるI/Oやリモートの副作用をロールバックするわけではないと理解しているか。
  • 読み取りバッファ、キュー、ロック、プロトコルフレームにおける部分的な進行状況(partial progress)を特定できるか。
  • 所有権、ステートマシン、冪等性キーを使用して、再開後のデータ損失や重複送信を防ぐことができるか。
  • どのTokioプリミティブがキャンセル安全性を文書化しており、どれがラッパーを必要とするかを把握しているか。
  • abort、タイムアウト、グレースフルシャットダウン、外部キャンセルを区別できるか。
  • モデルテスト、フォールトインジェクション、メトリクスを用いてキャンセルパスを検証できるか。

30秒の回答フレームワーク

「私はまずキャンセルの境界を定義します。futureをドロップするとそのタスクのポーリングは停止しますが、リモート操作が取り消されたことは保証されません。あるfutureがキャンセル安全であるのは、任意のawait後にそれをドロップして再度呼び出した場合に、正しく再開または再試行できる場合のみです。私は消費済みバイト、リクエストID、バッファ、冪等性キーを所有権を持つステートマシンで保持し、不可逆な副作用は永続化するか確認をawaitします。テストではすべてのawaitポイントでキャンセルを注入します。」

ステップごとの詳細解説

ステップ1:キャンセルポイントを特定する

非同期関数内のすべての .await を確認します。ローカルまたはリモートの状態がすでに変更されているか、futureがそこでドロップされた場合にどのフィールドが復旧可能かを検討します。関数の開始と終了だけでなく、ネットワークの読み書き、ロック待ち、チャネル受信、sleepなどをキャンセルポイントとして扱います。

ステップ2:キャンセルと取り消し(undo)を分離する

select! のブランチをキャンセルすると通常はそのfutureがドロップされますが、リモートサービスにすでに送信されたリクエストは継続する可能性があります。タイムアウトハンドラは、障害としてマークして非冪等な操作を盲目的に再送信するのではなく、リクエストIDと不明な結果(unknown-result)状態を記録します。取り消しがサポートされている場合は、プロトコルのキャンセル操作を呼び出し、その確認をawaitします。

ステップ3:復旧可能なステートマシンを設計する

準備、送信、確認待ち、コミット済み、補償(compensation)の各状態を表現します。明示的な所有者が状態とバッファを保持し、再開時に読み取りの継続、再送信、結果の照会、または補償のいずれを実行するかを選択します。ストリーミングプロトコルの場合、部分的なメッセージが途中から誤って再解釈されないよう、フレーム境界と確認済みオフセットを記録します。

ステップ4:キューとロックの一貫性を保つ

チャネルやストリームからアイテムを受信した後、永続的な処理を行う前にキャンセルされると、消費されたものの完了していないアイテムが発生します。トランザクショナルな確認応答(acknowledgement)、リピータブルリード、または永続的なリースを使用してください。select! ブランチ内のテンポラリ変数に唯一のコピーをpopしないでください。ロックガードはドロップ時に共有状態を解放しますが、リカバリ処理では保護された操作がコミットされたかどうかを記録します。

ステップ5:リソースとタスク終了の管理

JoinHandle::abort はタスクを停止しますが、ビジネスロジック上のクリーンアップを代替するものではありません。ソケット、ファイル、一時ファイル、セマフォパーミットにはRAIIガードを使用します。継続する必要がある処理は、ハンドルを保持した別のタスクに分離します。グレースフルシャットダウンでは、新規作業の受け入れを停止し、完了可能な操作の終了を待機してから、残りの処理にキャンセルを通知して記録します。

ステップ6:キャンセル安全性を検証する

各awaitの前後でキャンセルを注入し、再開、重複、損失、リソースリークをチェックします。制御されたモックI/O、モデルステートマシン、並行性テストにより、タイムアウト、チャネルクローズ、ピア切断、タスクabortをカバーします。インフライトリクエスト、重複キーヒット、補償回数、キャンセルレイテンシ、リークしたパーミットを追跡します。これらのメトリクスがなければ、「キャンセルされた」というのは単なる推測にすぎません。

トレードオフ、境界、および情報利得

キャンセル安全性とは、非同期の制御フローが中断されたときでもビジネス状態を説明可能に保つことを意味します。外部への副作用がない小さなfutureは簡単に再開できますが、ネットワーク、データベース、キューの操作には冪等性、確認、補償が必要です。すべてのタスクをキャンセル不可にすると、リソースのバグが隠蔽されシャットダウンレイテンシが増加するため、明示的なアトミック境界のみを保護します。

質の高い模範解答

「私はすべての .await をキャンセルポイントとして扱い、その前にどのような副作用が発生したか、ドロップからどのように復旧するかを検討します。Tokioの select! は選択されなかったfutureをドロップしますが、すでに送信されたリクエストを取り消すわけではありません。したがって、タイムアウト時はリクエストIDを保持し、冪等性キーを使用するか結果を照会した後にのみ再試行します。操作ステートマシンでは、準備、送信、確認待ち、コミット済み、補償を区別します。

チャネル受信、ファイル書き込み、データベースコミットでは、消費されたアイテムが失われないよう、トランザクショナルな確認応答やリピータブルリードが必要です。RAIIガードでリソースを解放し、完了が必須の処理は監視可能なハンドルを持つ別タスクで実行します。グレースフルシャットダウンでは、キャンセルして待機する前に新規受付を停止します。

各awaitでキャンセルを注入して、再開、重複、損失、リークをテストし、インフライト処理、重複キー、補償、キャンセルレイテンシ、パーミットを監視します。これにより、単にタスクが停止したことだけでなく、キャンセル安全性を実証できます。」

よくある間違い

  • ドロップをリモートのキャンセルと同一視する → リクエストはすでに実行中の可能性がある → IDを保持して照会するか、明示的なキャンセルAPIを使用する。
  • メッセージ消費直後にキャンセルを考慮する → 唯一のコピーが失われる可能性がある → 確認応答(acknowledgement)、リース、またはリピータブルリードを使用する。
  • タイムアウト後に盲目的に再送信する → 書き込みが重複して実行される可能性がある → 冪等性キーを使用するか、まず状態を照会する。
  • 関数の開始部分のみをテストする → awaitの間で競合が発生する → 各awaitでキャンセルを注入する。
  • クリーンアップの代わりにabortを使用する → ファイル、ロック、パーミットがリークする可能性がある → ガードとシャットダウンプロトコルを使用する。
  • すべてのタスクをキャンセル不可にする → グレースフルシャットダウンが無期限に待機する可能性がある → 真のアトミック境界のみを保護する。

フォローアップの質問と回答

Tokioプリミティブがキャンセル安全かどうかをどのように知ることができますか?

select! 内でキャンセルして再度呼び出してもデータが失われないという明示的な保証がドキュメントにあるか確認します。その保証がない場合は、部分的な進行状況が失われる可能性があると想定し、状態を保存してリカバリテストを作成します。

タイムアウト後にデータベース書き込みが完了したかどうかをどのように把握しますか?

クライアントのリクエストIDとユニーク制約を使用して結果を照会するか、照会可能な操作ステータスを公開します。クライアントのタイムアウトだけでは、非冪等な書き込みを再実行する根拠にはなりません。

JoinHandle::abort の直後にリソースを削除できますか?

タスクが停止し、そのリソースのドロップが完了した後に限られます。joinの結果をawaitするか、所有するガードにクリーンアップを実行させます。abortの発行は同期的完了ではありません。

どのような場合に処理を別タスクに移動すべきですか?

完了が必須である処理、リクエストを跨ぐ再試行が必要な処理、または現在のキャンセル境界でロールバックできない処理は、永続キューまたは独立したタスクに移動します。ハンドル、状態ストア、冪等性キーを用いてそれを監視します。すべてのキャンセルセマンティクスを回避する目的でdetachedタスクを使用してはなりません。

公開情報ソース

関連する質問

関連面接ツール

コーディング問題にはスクリーンショットを使用

問題をキャプチャし、制約条件、解法アプローチ、コード、エッジケース、計算量の順に進めます。

ツールを見る