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

Futurelock: 非同期Rustにおける微妙なリスク

概要

  • futurelock は、非同期Rustで発生する特有のデッドロック現象
  • 一つのタスクが複数Futureを管理し、その一部がリソースを保持したまま他のFutureの進行を妨げる構造
  • tokio::select!Mutex の使い方次第で簡単に発生
  • 問題の再現例と発生メカニズムの詳細な解説
  • 回避策 や設計上の注意点も紹介

futurelock(フューチャーロック)の概要と問題点

  • futurelock とは、Future Aが所有するリソースをFuture Bが必要とするが、両方を管理するTaskがAをもうpollしないため発生するデッドロック現象
  • Rustの非同期設計において 非常に見落としやすい罠
  • tokio::select!tokio::sync::Mutex などの組み合わせで頻発

再現コード例

  • 複数タスクで共有する Mutex を用意
  • バックグラウンドタスクでMutexを5秒間保持
  • メインタスクで tokio::select! により
    • future1: Mutex獲得を試みるFuture
    • future2: 500msスリープするFuture
  • 500ms経過後、future2がReadyとなりselect!は第2分岐へ進む
  • しかしfuture1はまだMutex待ち状態で、今後pollされずリソースが解放されない

問題の本質

  • tokio::select! は、最初にReadyとなったFutureの分岐だけを実行し、他のFutureはdropされる
    • ただし、&mut future1の参照がdropされても、future1本体はdropされず未完了のまま
  • Mutexは フェアな順番 で待機者にロックを譲る
    • 先に待機したfuture1が優先されるが、pollされなくなったため進行不能
  • 同じタスク が複数のFutureを管理し、一部しかpollされなくなる設計上の落とし穴

FAQ よくある疑問

  • なぜMutexが他のFutureを起こさないのか?
    • Mutexは次の待機者(future1)を正しく起こしているが、タスクがそのfutureをpollしないため意味がない
  • tokio::select!は複数Futureをpollし続けるのでは?
    • 最初にReadyになった分岐だけにコミットし、他のFutureはpollされなくなる仕様
  • future1はキャンセルされないのか?
    • &mut future1の参照がdropされるだけで、future1本体は生き続けるため、リソース解放が起きない

futurelock発生パターンと設計上の注意点

  • タスクT がFuture F1の完了待ちでブロック
  • F1 がリソース獲得などでF2に依存
  • F2 がTによるpollを待つが、TはF1しかpollしない
  • tokio::select!で&mut futureを分岐に渡し、他分岐でawaitすると高確率で発生
  • FuturesUnordered や独自Future実装でも同様のパターンに注意

具体的な回避策

  • select!に 所有権付き(owned)Future を渡すことで、分岐移行時に確実にdropされリソース解放
  • select!後に不要なFutureを 明示的にdrop する
  • タスク分割 (tokio::spawn等)で各Futureを独立したタスクとして管理
  • リソース取得順序や設計の見直し

タスクとFutureの違い

  • タスク はランタイムが実行する最上位の単位
  • Future はタスク内でpollされる実行単位
  • select!やFuturesUnorderedで 単一タスク内で複数Futureの同時進行 は可能だが、parallelism(並列性)はない
  • 並列実行が必要な場合は 各Futureをspawn して独立タスク化

まとめと参考情報

  • futurelock はプログラム上正しく見えても発生しうる深刻な問題
  • Rust非同期設計における リソース管理とタスク分割の重要性
  • 詳細な議論・実例は以下を参照

Hackerたちの意見

いい読み物だったし、サンプルコードも分かりやすかった。こういうの探すの大変だけど、見つけた時はまるで1000ピースのパズルが一瞬で組み合わさるみたいだね。

確かに。リモート企業で全てを記録していることの面白い副作用の一つは、「1000ピースのパズルが一瞬で組み合わさる」瞬間が記録されていることなんだ。正直、かなりすごいよ。この場合、4人のエンジニア(Eliza、Sean、John、Dave)間での共有ブレインストーミングだったし、彼らがこの状況を想像し始めて、それがソフトウェアに存在する条件そのものであることに気づく瞬間があった。月曜日にこれについてポッドキャストのエピソードをやる予定だし、その会話の前にその動画のクリップを出そうと思ってる。チームが一緒にデバッグしている様子を見るのは面白いからね。

FAQ: future1はキャンセルされないの? キャンセルって実際には2つの異なることが同時に起こることが多いけど、今回は違うんだ。1) futureがポーリングされなくなること、2) futureがドロップされること。この例ではドロップが遅れていて、futureがガードを保持しているから、その遅延には副作用があるんだ。だから、futureは「キャンセルされた」と言えるけど、リソースをまだ保持しているから「まだキャンセルされていない」とも言えるね。「この2つのことが常に一緒に起こるようにするのが実用的かどうか」気になるな。* 技術的には、ガードを取得するためのキュー位置を持つTokio内部のAcquire futureなんだけど、ガードを取得した後にも同じバグが現れる可能性があるから、ガードと呼ぼう。

ここにいるRustのデザイナーたちに聞きたいんだけど、なぜアクターパターンじゃなくて非同期デザインパターンを選んだの?少なくとも私には、アクターパターンの方がクリーンで間違えにくいように思えるんだけど。Erlangを使い始めてから、ソケットや非同期ワーカースレッドでの作業が多かったけど、やっと「正しい方法」を見つけた気がしたんだ。でも、通常はうまくいくけど、アクターモデルがその厄介な落とし穴を軽々と避けているように感じた。だから、その動機が何だったのか真剣に気になる。JSが非同期を使う理由は分かるけど、あの時点で言語の基本を大きく変えるのは遅すぎたからね。でもRustはクリーンなスタートだったのに。

Rustのデザイナーじゃないけど、Rustの非同期デザインの大きな動機は、組み込みで動かすことができるようにすることだったんだ。つまり、mallocもスレッドもなし。残念ながら、これがここでのデザインスペースの大部分を排除してしまうんだ。JS/C#/Goのようなアクティブなfutureからアクターモデルまで。Tokioを使えばアクターモデルでコードを書くこともできるけど、自然ではないよね。

_答え_はパフォーマンスだね。プログラム全体でアクター間通信のためにコピー可能なメッセージを作成する必要があるのは、高くつくことがある。とはいえ、完全にインライン化されて最適化された非同期状態マシンがそれほど重要でない部分もたくさんある。パフォーマンスに敏感な部分にはコンパイラ最適化が強力な非同期を使い、あまり敏感でないエリアにはアクターやチャネル、シングルスレッドタスクなどの高レベルの抽象を使うのは合理的だと思う。

これを知って驚いてる。趣味の組み込みやHTTPサーバーのOSSエコシステムが非同期にコミットしているのは知ってたけど、Oxideもそうだとは思わなかった。

アプリケーションに同時実行性が必要ってこと?じゃあ、全体を別のドメインに移行して、普通のやつとはほぼ互換性がないようにするってこと?それに独自の方言があって、互換性の壁もある?全然意味がわからないんだけど。

この動画を見ることをおすすめするよ: https://www.infoq.com/presentations/rust-2019/ そして、これを読むのもいいかも: https://tokio.rs/blog/2020-04-preemption 私はtl;drを書くのに向いてないけど、頑張ってみるね。アクターについて言えば、基本的にグリーンスレッドのことを話してる。RustにはCへの呼び出しにオーバーヘッドがないという厳しい制約があったから、グリーンスレッドは無理だった。Cは実際のスタックを期待するから、グリーンスレッドのスタックから実際のスタックを立ち上げてC関数を呼び出し、また戻す必要がある。Erlangも何か魔法のようなことをしていて、C FFIがブロックしても他のErlangアクターをブロックしないように別のスレッドプールに移動させることがある。一般的に、async/awaitは状態機械とイベントループにコンパイルされるからオーバーヘッドが低い。GoやErlangのような言語は素晴らしいけど、Rustは「速い」だけじゃなくてゼロコストの抽象化を目指しているシステムプログラミング言語なんだ。ある程度、オーバーヘッドと使いやすさのトレードオフがある。ガーベジコレクタは簡単だけど、Rustの借用チェッカー方式やmalloc/freeに比べるとオーバーヘッドがある。結局、トレードオフと何を作りたいかの問題だよね。ErlangやGoは、異なるトレードオフが意味を持つものを作ろうとしていた。 EDIT: Goがプリエンプションを導入する前は、同じように「落とし穴」があったことも指摘しておくね。もしゴルーチンがスタックの再割り当てをトリガーしなかったり(スタックを成長させる関数呼び出しのように)何かをしていなかったら、他のゴルーチンが飢えることがあった。今はGoがプリエンプションチェックを行って、スケジューラがホットループを中断できるようになってる。ErlangもRustと似たようにスケジューリングを行っていて、アクターには一定の予算があって、関数呼び出しがその予算を減らして、予算が尽きるとスケジューラに戻さなければならない。

これ、優先度の逆転に似てるね。例えば、高優先度のスレッドT_highが動いていて、低優先度のスレッドT_lowがロックを保持している場合、T_highはT_lowがスケジュールされるまで動けない。OSはこれを検出して、T_lowにT_highの優先度を「継承」させることができる。Tokioでも似たようなアイデアができるのかな?例えば、「動けない」futureが保持しているMutexを待っている場合、そのfutureをポーリングするみたいな。おそらく「動けない」ケースを検出するのにはかなりのオーバーヘッドが必要だと思うけど、できるかもしれない。特に難しいのは、直接的なawaitを使う必要がないことだよね。let future1 = do_async_thing("op1", lock.clone()).boxed(); tokio::select! { _ = &mut future1 => { println!("do_stuff: arm1 future finished"); } _ = sleep(Duration::from_millis(500)) => { // .awaitはないけど、両方がfuture1でロックを取得する。 tokio::select! { _ = do_async_thing("op2", lock.clone()) => {}, _ = do_async_thing("op3", lock.clone()) => {}, }; } }; つまり、「動けない」検出器は、他のタスクがそのfutureを実行しないことを判断し、そのfutureがこのタスクによってポーリングされている現在のセットに含まれていないことを確認する必要があるんだ。

「tokioで似たようなアイデアができるのかな?例えば、"動かない"未来が保持しているMutexを待っているとき、その未来をポーリングするって感じ。こういうのはTokioのタスクにとって意味があるかも。」 確かに、タスクスケジューラがどれくらい複雑かはわからないけど、もしかしたらもうこういうことをやってるのかもね。だけど、この投稿のようにタスク内の未来にはそれができない。これは非同期Rustの「未来は不活性」という設計に戻るんだ。未来を作ったりポーリングしたり、ポーリングを止めたりするのに、必ずしもランタイムと通信する必要はない。タスクレベルでランタイムと話す必要があるのは、新しいタスクを生成したり、自分のタスクを起こしたりするためだけ。未来はほとんどただの普通の構造体で、Tokioは自分の非同期関数が内部で何個の未来を作っているかなんて、整数や文字列、ハッシュマップのことを知るのと同じくらい知らないんだ。

Rustの非同期は色付きスタックレスコルーチンモデルだと思ってたから、以前実行していた非同期関数の実行を続けるのは危険だと思ってた。一般的に言うと、スタックレスコルーチンの非同期は、実際には「独立したスタック」レスコルーチンだから色付けが必要なんだ。実際には、ローカル状態のためにスタックを共有している。これにより、非同期関数の実行がLIFO順で進むことになり、直後に実行される非同期関数のスタックを吹き飛ばさないようにする必要がある。これが、スタックフルコルーチンモデルとは違って色付けが必要な理由なんだ。スタックフルコルーチンモデルは、ローカル状態が安全な場所に保存されているから、任意の順序で実行、イールド、完了できるんだ。

Hacker Newsで議論の続きを見る