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

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

概要

  • 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はおそらく安全じゃない。チャンネルからメッセージを読み取った後に、タイムアウトの完了を選んでしまうと、そのメッセージを捨ててしまう可能性があるからね。解決策は似ていて、タイムアウトか「何かが利用可能」という選択をしたい。後者を選んだ場合は、利用可能なデータを覗くことができる。

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

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

最初の例では、望ましい動作が明確じゃないね。キューが満杯のとき、基本的な選択肢は何かをドロップするか、ブロックして待つか、パニックになるかだ。ブロックでタイムアウトするのは通常デッドロック検出だよね。彼は「このコードはしばしば間違っていることが分かりました。なぜなら、すべてのメッセージがチャンネルに届くわけではないからです。」って書いてる。まあ、そうだね。リソースが足りない。さて、どうする?クリーンなプログラムのシャットダウンを目指してるの?スレッドプログラムではそれは中程度に難しいし、非同期にも問題がある。ここでのユースケースは不明瞭だね。実際のユースケースは、リモートサイトにメッセージを送受信しているときに、そのリモートサイトが消える場合だよ。そうなると、自分の側の状態を処理する必要がある。

彼は「彼らはthey/sheで呼ばれます」って書いてる。 https://sunshowers.io/about/

理想的には、メッセージをバッファリングして、チャンネルにスペースができるまで待つのがいいよね。この話は後で「何ができるか」というところで触れるよ。

例の中に入ってるよね?例では「5秒間スペースがありません」ってログが出てる。それは単に役立つ診断情報だけど、微妙にデータ損失に繋がってる。ちょっと無理があるかもしれないけど、「何も起こってないように見えるけど、理由が分からない」って時に、システム全体に散りばめるようなコードだと思う。

いいトークだった!自分みたいな初心者にとって、SOPではFutureがキャンセルできないってことを最初に説明してくれると良かったな。.awaitがFutureの所有権を持つから、drop()が呼ばれないのは知ってたけど、Futureが遅延評価されるから、.awaitを呼んだ後にどうやってキャンセルするのかがよく分からなかった。後でselect!Abortable()がどうやってこれを実現するか調べたけど、もしまたトークすることがあったら、最初にその点を触れてくれるといいな。まあ、全体的には素晴らしい仕事だったよ!

ありがとう!この文脈でSOPって何の略なの?

これらのFutureがキャンセルされることに何が問題なのか、よく分からないな。Futureはタスクじゃないって投稿でも認めてるし、何らかの理由でFutureが完了に向かって進まないなら、Futureが完了することを期待しないのは論理的じゃない?他に何が起こることが期待されるの?「キャンセルが安全でない」Futureの例は、期待と現実のミスマッチが根本的な問題だと思う。例1: 片方のFutureがエラーでキャンセルされた場合 let res = tokio::try_join!( do_stuff_async(), more_async_work(), ); 例2: キャンセル時にデータが書き込まれない let buffer: &[u8] = /* ... */; writer.write_all(buffer)?; これらのケースは、作業が中断されて完了に至らないからキャンセル安全じゃないとされてる。でも、他に何が起こるべきなの?非同期コンテキストがキャンセルされても作業を終わらせたいなら、同じ非同期コンテキストに入れずにタスクをスパーンすればいいと思う。著者の問題を理解するのに何か明白なことを見逃してる気がする。キャンセル時に作業がドロップされるのがFutureの動作だと思ってたんだけど、何か見逃してるニュアンスがあるのかな?

その通りだね!問題は、これがOxideで多くのバグを引き起こしてるってことなんだ。Futureが受動的で、任意のawaitポイントでキャンセルできるって考えを完全に内面化してしまったら、トークはただの詳細の羅列になっちゃうよね。

https://github.com/fast/mea

https://www.reddit.com/r/rust/comments/1gfi5r1/comment/luido...

RustのFutureは、C++のムーブセマンティクスに似てて、終わった後に無効な状態に置くことがあるんだ。さらに、Rustはスタックレスコルーチンの設計を採用してるから、手動でポールベースの非同期構造を実装したいなら、構造体の中で状態を維持する必要がある。これらは全部、よくある罠だよね。そして、今の非同期Rustにおけるキャンセルは、非同期Rust(Futures)における状態管理の新しい補完となってる。私がmea(Make Easy Async)[1]ライブラリを開発しているとき、非自明なときにはキャンセル安全属性を文書化してるよ。それに、[2] 無思慮な非同期キャンセルがIOスタックを混乱させる事例を思い出すな。