非同期 Rust におけるキャンセル処理
(sunshowers.io)- 非同期 Rust 環境での キャンセル処理 は便利だが、扱いを誤ると予期しないバグや難しさが生じる
- 同期 Rust では明示的なフラグ確認やプロセス終了などが必要だが、非同期 Rust では future を drop するだけで非常に簡単にキャンセルできる
- キャンセル安全性(cancel safety)とキャンセル正当性(cancel correctness)は異なる概念であり、1つの future のキャンセルがシステム全体に問題を引き起こす可能性がある
- キャンセルに関連する主な問題パターンには、Tokio mutex、select マクロ、try_join、そして future の使い方のミスなどがある
- 完全な解決策はないが、キャンセル安全な API の利用、future の pin 固定、task の分離などによって キャンセルによる問題を減らせる
はじめに
- 本稿は 非同期 Rust のキャンセル処理(cancellation) に関する RustConf 2025 の発表内容に基づく
- 一般的な Rust の非同期コード例でメッセージ受信または送信ループに timeout を追加すると、しばしばメッセージが失われる問題が見つかる
- Oxide Computer Company などの実際の大規模システムで async Rust を活用する中で経験したキャンセルの問題や実際のバグ事例を扱う
- この記事は 1) キャンセルの概念、2) キャンセルの分析、3) 実践的な解決策、の3つの部分で構成されている
- 筆者は Rust の signal handling、cargo-nextest の開発などを通じて 非同期 Rust の利点と難しさを経験してきた
1. キャンセルとは何か?
キャンセルの意味
- キャンセル(cancellation) とは、ある非同期処理を開始した後、その途中で処理を中断する状況を指す
- 例: 大容量ダウンロード、ネットワークリクエスト、部分的なファイル読み取りなどは途中でキャンセルできる
同期 Rust におけるキャンセル方法
- 一般に アトミックなフラグ によって定期的にキャンセル有無を確認するか、特殊な例外(panic)の利用、プロセス全体の強制終了といった方法がある
- 一部のフレームワーク(Salsa など)は panic payload を使うが、Rust のすべてのプラットフォームで動作するわけではない(特に Wasm 環境など)
- スレッドだけを強制終了することは、Rust の安全性と mutex の仕組み上、許可されていない
- 要するに、同期 Rust には汎用的で安全なキャンセルプロトコルは存在しない
非同期 Rust: Future とは何か?
- Future は Rust コンパイラが生成する 状態機械(state machine) であり、メモリ上の単なるデータにすぎない
- 作成しただけでは実行されず、await または poll が呼ばれたときにのみ進行する
- Rust の Future は受動的(inert)であり、明示的な poll/await がなければ何の処理も行わない
- Future を作成するとすぐに実行を開始する Go / JavaScript / C# などとは対照的である
非同期 Rust のキャンセルプロトコル
- Future のキャンセル は、単に drop するか、poll/await をそれ以上呼ばないことで行われる
- 状態機械であるため、Future はいつでも破棄できる
- 非同期 Rust におけるキャンセルは非常に強力でありながら、簡単に適用できる
- しかし あまりにも簡単に future が静かに drop され、その所有権モデルに従って子 future まで連鎖的にキャンセルされる
- この特性のため、キャンセルは 非局所的(non-local)な現象 となり、呼び出しチェーン全体に影響を及ぼす
2. キャンセルの分析
キャンセル安全性とキャンセル正当性
- キャンセル安全性(cancel safety) : 個々の future が副作用なしに安全にキャンセルできる性質
- 例: Tokio の sleep future はキャンセル安全である
- 一方で、Tokio の MPSC send は drop 時にメッセージ消失の危険があり、キャンセル安全ではない
- キャンセル正当性(cancel correctness) : システム全体がキャンセル時にも本質的な性質を維持できるかというグローバルな性質
- キャンセル安全でない future が システム内に存在しないなら 正当性の問題はない
- キャンセル安全でない future が実際にキャンセルされたときにのみ問題が発生する
- キャンセルによってデータ消失、不変条件の違反、クリーンアップ漏れが起きれば、キャンセル正当性違反となる
Tokio mutex の難しさ
- Tokio mutex はロックを取得し、データを補正してから解放することで動作する
- 問題: lock の内部で状態を一時的に破る(例: Option<T> を None に変更する)操作を行った後、await をまたいだ時点で future がキャンセルされると、誤った状態のままデータが固定されてしまう
- 実際の現場(例: Oxide における sled の状態管理)でも、await ポイントでのキャンセルにより不安定な状態が発生した
- このように、非同期コードにおける状態管理ではキャンセルが非常に危険な欠陥の原因となる
キャンセル発生パターンと例
- .await を付け忘れた future 呼び出し: Rust は未使用 future に警告を出すが、Result の戻り値を _ で受けた場合は警告されない(Clippy の最新 lint 適用が必要)
- try_join などの try 演算: 1つの future が失敗すると残りがキャンセルされる(実際のサービス停止ロジックでバグにつながる)
- select マクロ: 複数の future を並列処理した後、完了したもの以外の future をすべてキャンセルする(select ループでデータ消失などの危険が大きい)
- これらのパターンはドキュメントにも記載されているが、実際にはさまざまな場所で非同期キャンセルが暗黙的に発生しうる
3. 何ができるか?
- キャンセル正当性に関する問題には、根本的かつ完全な解決策はまだない
- ただし実務上は、次のような方法でキャンセル由来の欠陥を減らせる
キャンセル安全な future への再構成
- MPSC send の例: 予約(reserve)と実際の送信(send)を分離することで、部分的なキャンセル安全性を確保できる
- 予約処理はキャンセルしても関連メッセージは失われない
- permit を取得した後は、キャンセルを心配せずに送信できる
- AsyncWrite の write_all: バッファ全体への write_all はキャンセルに不安定だが、write_all_buf はバッファカーソルを使ってキャンセル時の進行状況を追跡できる
- ループ内で write_all_buf を使えば、部分的な進行状態から安全に再開できる
キャンセルを避ける future の運用
- future の pin 固定: select ループなどで future を pin で固定し、キャンセルされないよう参照経由で poll しながら待機する
- 例: reserve future を再利用すれば、予約待ちの順番を維持できる
- task の活用: tokio::spawn などで future を task として実行すると、ハンドルを drop しても task 自体はランタイムが別途管理するため、強制的にはキャンセルされない
- Oxide の Dropshot HTTP サーバーなどでは、各リクエストを別 task で実行し、クライアント接続が切れてもリクエスト処理の完了を保証している
体系的な解決策?
- 現在の safe Rust の範囲では制約があるが、議論されているアプローチはいくつかある
- Async drop: future がキャンセルされたときに非同期クリーンアップコードの実行を可能にする
- 線形型(linear types) : drop 時に特定コードの実行を強制したり、特定の future をキャンセル不可として示したりする
- これらの方式はいずれも実装上の難しさがある
結論と推奨事項
- Future は受動的(passive)であるという特性 を根本的に理解する必要がある
- キャンセル安全性(cancel safety) と キャンセル正当性(cancel correctness) の概念を把握しておくべきである
- 主要なキャンセルバグの事例やコードパターンを理解し、事前に対応戦略を準備する必要がある
- いくつかの実践的な推奨事項
- Tokio mutex の使用は避け、代替手段を検討する
- 部分完了 API やキャンセル安全な API を設計・活用する
- キャンセル安全でない future には、必ず完了が保証されるコード構造を採用する
- さらに、cooperative cancellation、actor モデル、structured concurrency、panic safety、mutex poisoning などの発展的なテーマも検討が推奨される
- 関連資料は sunshowers/cancelling-async-rust を参照できる
お読みいただきありがとうございました。発表および参考資料の確認とフィードバックを提供してくれた Oxide の同僚たちに感謝する
1件のコメント
Hacker Newsの意見
こうした事例はどれも、context が cancel されて作業が完了しないのは当然の動作だ。作業が必ず終わらなければならないなら、独立した task に切り出せばいいだけだ。何か重要な nuance を見落としているのかもしれないが、もともと work が cancellation によって消えるのが futures の設計意図だと理解しているので、何が問題なのかもう一度整理してほしい