3 ポイント 投稿者 GN⁺ 2025-10-05 | 1件のコメント | WhatsAppで共有
  • 非同期 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件のコメント

 
GN⁺ 2025-10-05
Hacker Newsの意見
  • send/recv に timeout を設ける例がとても興味深いと思った。実行されていない状態で即座に、ポーリングなしで future が実行される言語では、むしろ逆の状況が起こり得ることが分かった。send に timeout を付けると、timeout 後にもメッセージが送信されることはあるが、メッセージ自体は失われないので安全だ。一方で recv に timeout を付けると、チャネルからメッセージを読んだあとで timeout が選ばれる状況では、そのメッセージをそのまま捨ててしまうので安全ではない可能性がある。解決策は、timeout か「何かが利用可能であること」をチャネルで選択するようにし、後者の場合は peek を通じてデータを安全に確認することだ
    • これこそが cancellation-safety の核心なのではないかと考えている
    • 良い指摘だと思う
  • このテーマに関して自分が書いた資料をいくつか紹介したい
    • async 関数は必ず最後まで実行されるべきだという提案書を 2020 年に書いた。graceful cancellation の機能も含まれており、今でもこれより良いアイデアは出ていないと思っている 提案書リンク
    • sync と async の Rust 全体にわたる unified cancellation のための提案もある("A case for CancellationTokens") gist リンク
    • 上記を実際に実装した例もある min_cancel_token
  • futures がキャンセルされることの何が問題なのか、よく分からない。futures は task ではなく、その記事でも内部的にその点は認めている。だとすると、future が最後まで実行されなくても、もともとそういうものではないのか。そして、そうした状況がなぜ問題なのか理解できない。例では "cancel unsafe" な future だと主張しているが、本質は期待と現実の食い違いだと思う
    • 例1は try_join のうち一つがエラーになって cancel
    • 例2はキャンセル時にデータが書き込まれない
      こうした事例はどれも、context が cancel されて作業が完了しないのは当然の動作だ。作業が必ず終わらなければならないなら、独立した task に切り出せばいいだけだ。何か重要な nuance を見落としているのかもしれないが、もともと work が cancellation によって消えるのが futures の設計意図だと理解しているので、何が問題なのかもう一度整理してほしい
    • その通り! 実際に Oxide ではこれによって多くのバグが発生したことがある。futures は受動的で、await ポイントごとにいつでもキャンセルされ得るという点を十分に理解すれば、残るのは細かなテクニックだけだ
  • RustConf でこの発表を本当に面白く聞いた。cancel safety と cancel correctness という概念の区別がとても有用だ。発表がブログ記事にもなっていて本当にうれしい。発表も良いが、ブログとして整理されているほうが共有や参照がしやすい
    • "cancel correctness" という表現は cancellation の文脈をうまく捉えていて気に入っている。一方で "cancel safety" という用語はあまり好きではない。Rust の safety 概念ともぴったり一致しないし、不要に判断的な響きがある。safe/unsafe は良し悪しをより強く示唆するが、cancel の動作が望ましいかどうかは状況次第だ。たとえば、spawn された task を待つ future は "cancellation safe" と呼ばれるが、drop 時にも task が走り続けるなら、不要な仕事が積み上がり、lock や port も占有して問題になることがある。むしろ drop 時に task を止める spawn handle は "cancellation unsafe" と呼ばれるものの、dependent task の cleanup には非常に重要なパターンだ
    • ブログ記事のほうが読みやすくて良いと思う、同感だ
  • https://sunshowers.io/posts/cancelling-async-rust/#the-pain-of-tokio-mutexes の内容が特に興味深かった。自分も簡単にああいうミスをしそうだ
    • 自分は Go 開発者だが、こういう部分は役に立つ。Rust はツールがより厳密に助けてくれるが、goroutines、チャネル、select、その他の並行性 primitives でも Go で同じ落とし穴にはまりやすい
  • 最初の例で望んでいる動作が何なのか不明確だ。キューがいっぱいなら drop、待機、panic のどれかを選ぶ必要がある。ブロッキングにタイムアウトを掛けるのは主にデッドロック検出のためだ。コードは「すべてのメッセージがチャネルに行くわけではない」と言っているが、リソースが足りなければそれは当然だ。目的は何なのか? きれいなプログラム終了か? それはスレッド環境でもかなり難しく、async でも簡単ではない。実際のユースケースは、リモートとのメッセージ交換で相手が切断されたときに自分側の状態を片付けることだ
    • 理想的には、チャネルに空きができるまでメッセージをバッファに保持したい。この話は発表後半の "What can be done" で扱われている
    • 例に答えがある。5 秒間空きがないときにロギングするコードは診断目的だが、これが思いがけずデータ損失につながる危険がある。少し作為的ではあるが、実際に「なぜ動かないんだ?」のような問題への対処コードとして、システムのあちこちに付け足しやすい
    • ちなみにこの記事の著者は they/she の代名詞を使っている about
  • await は常に潜在的なリターンポイントであることを意識すべきだ。必ず一緒にアトミックに実行される必要がある 2 つのアクションの間に await を置くのは避けたほうがよい
    • これが実際にどう問題を起こすのか気になる。たとえば、
      async fn a() {
        b().await
      }
      async fn b() {
        c().await
        d().await
      }
      async fn c() {}
      async fn d() {}
      
      このコードでは、どういう形で d が呼ばれない問題が起こるのか? c でキャンセルが発生するのか? それとも a の上位で何か起きるのか?
    • ではこれは少し危険なのでは? もちろん避けられない面もあるが、「critical section」に await が 2 回あると、その間で一時停止されるものの、最終的には続けて実行されなければならない状況があり得る。たとえば DB を変更したあとで audit log を残す場合、両方とも必ず実行される必要があるなら、単に do not cancel コメントを付けるしかないのか気になる
  • Rust の Future は C++ の move semantics のように、Future が終わったあとは invalid state になることがある。Rust は stackless coroutine 設計なので、poll ベースの async 構造を直接実装するときは状態を struct で自前管理しなければならない。こうした点はどれもよくある落とし穴だ。そして最近の async Rust では cancellation が state management に新たな変数として加わっている。自分が mea(Make Easy Async)ライブラリを開発していたときも、cancel safety が trivial でないなら必ず文書化していたし、軽率な async cancellation が IO スタックに問題を起こした事例も覚えている mea reddit 事例
  • 本当に良い発表だった! 完全な初心者としては、SOP では Future をキャンセルできない点を最初に強調してくれていたらと思った。.await が future を所有するので drop() できず、future は lazy だから .await の後で cancellation がどうなるのか明確ではなかった。その後 select! と Abortable() を調べて理解したが、今後発表するなら最初にこの点も強調してくれれば完璧だと思う
    • 質問。ここで SOP が何を意味するのか気になる
  • 本当にタイミングが良かった。今日ちょうど新しい関数の doc comment に「この関数は cancel safe です」と書いていたところで、こういうことを考えていた。早く async drop が使えるようになってほしい
    • その関数が気になる。もう少し説明してもらえるとうれしい