世界を動かす技術を、日本語で。

非同期Rustにおけるキャンセル

2025年10月4日原文(sunshowers.io)

概要

  • RustConf 2025 での非同期Rustにおけるキャンセルについての講演内容の要約
  • async Rust におけるキャンセルの仕組みと、その強力さと落とし穴
  • キャンセル安全性キャンセル正当性 という2つの重要な概念の解説
  • 実際のコード例 とともに、問題点とその対策を紹介
  • Tokio などの実例を交えつつ、実践的な知見を共有

Rustのasyncキャンセルの本質と落とし穴

  • async Rust では、キャンセルは「作業を始めた後でやめる」ことを意味
  • ネットワーク通信やファイル読み込み など、長時間かかる処理の中断手段
  • 同期Rust では、キャンセルにはフラグ管理やpanic、プロセス終了など複数手法が存在
    • フラグ管理 :atomic変数でキャンセル判定
    • panic利用 :一部フレームワーク(Salsaなど)で利用、ただしWasm等では非対応
    • プロセス・スレッド終了 :安全性やリソース管理の観点からRustでは非推奨
  • 共通プロトコル不在 が同期Rustの課題

async Rustでのキャンセルの仕組み

  • Future は「状態マシン」としてメモリ上に存在、await/pollされるまで何もしない
  • 他言語(Go, JS, C#) ではFuture生成時点で即実行されるが、Rustは「完全な遅延実行」
  • キャンセル方法 はFutureをdropするだけ
    • 親Futureのキャンセル子Futureにも伝播
  • 簡単にキャンセルできる 反面、思わぬバグや副作用を生みやすい

キャンセル安全性とキャンセル正当性

  • キャンセル安全性(cancel safety) :Futureを途中でキャンセルしても副作用がない性質
    • 例: tokio::time::sleep はキャンセル安全
    • 例: tokio::sync::mpsc::Sender::send はキャンセル非安全(途中でdropするとメッセージ消失)
  • キャンセル正当性(cancel correctness) :システム全体としてキャンセルにより不整合やバグが起きない状態
    • キャンセル安全性 はローカルな性質、 キャンセル正当性 はグローバルな性質
    • キャンセル非安全なFuture が存在し、それがキャンセルされ、かつシステムの性質を壊す場合にバグとなる
  • Tokio Mutex の例:
    • ロックの待機中にキャンセル すると、順番待ちの整合性が壊れる危険性
    • ドキュメントにも注意書き あり

実際の問題例と対策

  • 送信チャンネル(Sender::send) でのタイムアウト実装例
    • timeoutでキャンセル すると、送信予定だったメッセージが消失
  • システムのキャンセル正当性 を守るための実践的対策
    • キャンセル非安全API の利用時は、キャンセルされても副作用がないように設計
    • 重要な操作は必ず完了させる、もしくは失敗時のリカバリ処理を実装
  • キャンセルの伝播 に注意し、 親子Future間の関係 を意識した設計が必要

まとめと提案

  • async Rustのキャンセル は強力だが、落とし穴も多い
  • キャンセル安全性と正当性 の両面からシステム設計を見直す重要性
  • Tokio等のドキュメント や実例を参考に、 本番運用でのバグ事例 から学ぶ姿勢
  • キャンセル非安全なAPI の利用時は、 キャンセル伝播と副作用 を必ず確認
  • async Rustの強みを活かしつつ、堅牢な設計・運用 を目指すことが重要

Hackerたちの意見

タイムリーだね!今日、新しい関数のドキュメントコメントに「この機能はキャンセル安全です」って追加して愚痴ってたところだよ。早く非同期ドロップが来てほしいな。

ちょっと興味あるな。その関数についてもう少し話してくれる?

awaitは常に潜在的なリターンポイントだってことを忘れない方がいいよ。だから、常に一緒に実行されるべきアクションの間でawaitを使うのは避けた方がいい。

それ…悪そうだね?仕方ないとはいえ、もし「クリティカルセクション」に2つのawait呼び出しがあったらどうなるの?コードはその間に一時停止できるけど、最終的には再開しなきゃいけないよね。データベースに変更を加えて、その変更の監査を発行する場合とか。唯一の選択肢は、それをやらないか、関数のドキュメントに「キャンセルしないで」って大きく書くことだけ?

ちょっと待って、これは実際にはどう動くの?例えば、私のコードがこんな感じだとするよ。

async fn a() { b().await }
async fn b() { c().await d().await }
async fn c() { }
async fn d() { }

dが呼ばれない原因はどこにあるの?cの中で何かキャンセルが起こってるの?それともaの上流のアクション?

今年のRustConfでのトークの中で、これが一番好きだった!キャンセル安全性とキャンセル正確性の違いは本当に役立つね。ブログ記事に変わって嬉しい。トークもいいけど、ブログは共有しやすいし、参照もしやすいからね。

ありがとう!私もトークを見るよりブログを読む方が好きだな。

「キャンセルの正しさ」っていうのはすごく納得できる。キャンセルをある文脈に置くからね。「キャンセルの安全性」っていう言葉はあんまり好きじゃないな。Rustの安全性の概念とは関係ないし、余計な判断を含んでる気がする。安全/危険っていうのは、より良い行動や悪い行動があることを示唆するけど、キャンセルに何を望むかは文脈によるからね。生成されたタスクを待っているFutureは「キャンセル安全」と呼ばれるけど、ドロップされたときにタスクを止めないから。でも、それが本質的に安全な行動とは限らない。生成者がキャンセルされた後もタスクを動かし続けるのはバグになる可能性があるし、使われない作業が溜まったり、ロックを保持したりポートを使ったりしてプログラムの他の部分に干渉するかもしれない。一方で、ドロップされたときにタスクを止めるspawn handleは「キャンセル安全ではない」と呼ばれるけど、依存するタスクにクリーンアップを伝播するためにはすごく便利な構造なんだよね。

そのトピックについて私が書いた他の資料もあるよ: - 2020年の提案で、完了するまで実行されることが強制される非同期関数について(必要なら優雅なキャンセルを使う)。ちょっと古いけど、今のところこれより良いアイデアは出てきてないと思う。 https://github.com/Matthias247/rfcs/pull/1 - 同期と非同期のRust間での統一キャンセルの提案(「キャンセルトークンの必要性」 - https://gist.github.com/Matthias247/354941ebcc4d2270d07ff0c6...) - 上記の実装の探求: https://github.com/Matthias247/min_cancel_token

タイムアウト付きのsend/recvの例はとても興味深いと思う。なぜなら、未来がポーリングなしですぐに実行される言語では、状況が逆になる可能性があるから。タイムアウト付きのsendはおそらく安全だと思う(タイムアウトが発生しても送信できるかもしれないし、悲しいかもしれないけど、メッセージは失われない)。でも、タイムアウト付きのrecvはおそらく安全じゃない。チャンネルからメッセージを読み取った後に、タイムアウトの完了を選んでしまうと、そのメッセージを捨ててしまう可能性があるからね。解決策は似ていて、タイムアウトか「何かが利用可能」という選択をしたい。後者を選んだ場合は、利用可能なデータを覗くことができる。

ありがとう、いいポイントだね。

これってまさにキャンセル安全性のことじゃないの?

Hacker Newsで議論の続きを見る