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

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順で進むことになり、直後に実行される非同期関数のスタックを吹き飛ばさないようにする必要がある。これが、スタックフルコルーチンモデルとは違って色付けが必要な理由なんだ。スタックフルコルーチンモデルは、ローカル状態が安全な場所に保存されているから、任意の順序で実行、イールド、完了できるんだ。

私の見解では、基本的な2ロックのデッドロックに近いと思う。スレッド1がAを取得し、スレッド2がBを取得する。スレッド1がBを取得しようとして、スレッド2がAを取得しようとする。この場合、役割「A」はMutexのロックキューの前方が担っていて、役割「B」はTokioのアクティブに実行されているタスクが担っている。この理解に基づいて、驚くべき動作はTokioのMutex/ロックキューの実装によるものだに同意するよ。もしこれがOSのMutexだったら、Mutexを待っているスレッドが何らかの理由で起きられない場合、OSはそのMutexを待っている別のスレッドを起こすことができる。こういうアプローチの難しさは、Rustの非同期がどのように実装されているかに関係していると思う。私の推測では、ロックを解放するアルゴリズムはこんな感じだと思う:1. 待機キューの先頭をポップする。2. Mutexを保持しているFutureのトップレベルのtokio::spawnされたタスクをポーリングする。待機キューの各Future(前から後ろへ)について、Futureをポーリングする。成功したら、ブレイク。すべてが失敗したら何か?これがうまくいかない理由は、未来がどのように構成されるかに関係している。未来は状態機械内の状態にコンパイルされる。待機キュー内でポーリングされた未来が完了したら、制御フローは呼び出し元にどのように戻されるのか?おそらく、未来を独立してポーリングしてから、トップレベルの未来をポーリングして、物事を解決しようとするフォールバックがあるかもしれない。でも、これだと、コード内のどのパスもそれをawaitしていないのに、未来がポーリングされるという混乱した動作を引き起こす可能性がある。これが良いかもしれないけどね?

ちょっと話がそれるけど、そのコードはかなり…複雑に見えるね。ErlangやElixir、Go、さらにはCで書くのとは全然違う。私だけかな?

なるほど、後から考えると納得だけど、コード見ただけじゃすぐにはわからなかったな。すごく巧妙だね。いいブログ記事だ。

確か、他の人と一緒に非同期Rustのエコシステムでこれを作ってたとき(エリザ、元気?)に、あるトレードオフがあったんだ。参照で選択できなければ、この問題にはぶつからない。でも、同じロックを取得しようとしたり、同じチャンネルから読み取ろうとしたりするために、whileループでselect!を使うこともできなくなる。キューの位置を失わずにね。このことや、前に話したキャンセルの問題が、経験豊富なRustの専門家でも驚くような問題を引き起こすことに同意するよ。でも、非同期Rustの主要な運用モデルの下で本当に改善できることが何かはわからないな(すべての未来がドロップできるから)。コールバックを使うのと比べると、驚くようなことはまだかなり少ないけどね :)

「同じロックを取得しようとしたり、同じチャンネルから読み取ろうとしたりするために、whileループでselect!を使うこともできなくなる。」 いや、選択されなかった未来をドロップする代わりに、たくさんの所有された未来に対してselect!()を使えば、状態を失わずに済むよ。確かにこれは不便だけど、論理的には唯一の整合性のある方法だと思う。おそらく、これを使いやすくするマクロの魔法があるんだろうけど。でも、これでも根本的な原因は解決しないよ。所有された未来をドロップすることは、きれいにキャンセルすることが保証されてないからね。根本的な原因については、ここを見てね: https://news.ycombinator.com/item?id=45777234

確かにその通りだね(マティアス、こんにちは!)。このデッドロックの原因を突き止めた後、同僚たちと「どうやってこれを防げたか?」っていう典型的な会話をしたんだけど、実際には簡単に責められるものは何もなかったっていう、ちょっと悲しい結論に至ったんだ。関わったすべてのTokioのプリミティブは、正確に機能してたからね。Rustの非同期を根本から再設計しない限り、これを防ぐ唯一の方法は、select!の中で&mut futureを使うのを禁止することだったけど…それだと正しいコードもたくさん消えちゃう。そうできないと、多くのアプリケーションが表現したいことを表現するのが難しくなると思う。これについては、こちらのコメント[1]でも少し話したよ。一方で、バグを見つけたコードを書いた同僚を責めることもできなかった。彼はすべてを正しくやって、正しい方法で組み合わせていたから。すべての部分は正しく機能していて、彼のコードもそれを正しく使っているように見えたけど、これらの部分の相互作用がデッドロックを引き起こしたんだ。彼が予測するのは非常に難しかったと思う。だから、私たちの結論は、うーん、これはちょっと残念だねってこと。非同期Rust全体を否定するわけじゃなくて、個々にうまく設計された部分の相互作用から生じた不幸な現象なんだ。気をつけなきゃいけないことだね。認めるのはちょっと悲しいけど。

ざっと見た感じ、この文書はかなりしっかりしてて透明性があるね。明らかに、厳しい教訓を学んだって感じ。特に脚注が目を引いたよ。

「この状況がダメな理由は何?」 多くの人がキャンセルの安全性について知らなかったのは明らかだし、オミクロン全体にキャンセルの問題がたくさんあるみたい。製品をできるだけ早く出そうと頑張ってるのに、見つけるのも難しい悪いバグがたくさんあるって知るのは本当にストレスだよね。Cのメモリ安全性の問題と似てるのがイライラする。プログラマーが保証すべき動的な特性があって、コンパイラはそれに対して何の助けもできない。間違った場合の失敗モードはデバッグが難しいことが多いし(プログラムが本来やるべきことをしてないから、デバッグやコンソールで見えるログメッセージや残存状態がない)、失敗モードは無限にダメージを与える可能性がある(クラッシュ、ハング、データ破損など)。この挙動が、async/awaitエコシステムの中で一つの(人気のある)クレートのマクロ以外ではほとんど文書化されていないってのも、ほんとにイライラする。これって、Rustのコア原則に反してるように感じる。プログラマーがコンパイル時にコードが正しく形成されていることを示さなければならないことで、こういう狡猾なランタイムの挙動を避けるはずなのに。

これは、withoutboatsのFuturesUnorderedの投稿で説明されているデッドロックのかなり微妙なバージョンだね。

「“intra-task”の並行性を使うときは、未来が飢えていないことを確認する必要がある。」 タスクを生成するのがデフォルトになるべきだと思う。タイムアウトにはtokio::select!を使うけど、すべての保留中の未来がそれに所有されていることを確認してね。エッジケースをすべてテストしない限り、FuturesUnorderedを勧めることは絶対にないよ。

ほんとに信じられないけど、Rustがアクティブなタスクを進めることを許可しないのはひどいよね。理由もなく、理解不能なバグのクラスを生み出してる。Rustの専門家に、なんでこうなってるのか説明してもらえないかな?これは明らかに無駄なエラーに見える。Pythonでは、Trioライブラリをよく使ってるんだけど、これは「構造化された並行性」を提供してる。タスクは(のみ)レキシカルスコープに生成され、スコープを離れる前にすべて完了(待機)するんだ。それにはキャンセルされたタスクを待つことも含まれていて(これらは有用なasync作業を行うことができる)、自分のタスクスコープが完了するのを待つこともできる。Rustもそんなことできるのかな?従来のasyncプログラムよりも考えやすいし、Rustに合ってる気がする。ボーナスとして、この問題も解決できるみたいで、Rustの同等のものはおそらくすべてのタスクが所有スコープによって暗黙的にポーリングされるだろうし。

タスクと未来の違いがあるんだよね。未来はポーリングされるまで何もしないし、asyncランタイムに特別なことはないから(ユーザーレベルのコードなだけ)、未来を作ってポーリングしない、またはポーリングをやめることは常に可能なんだ。タスクは異なる構造で、通常はランタイムに結びついてる。RFDの提案を見ると、未来をその場でポーリングするのではなく、タスクを明示的に使うことを呼びかけてる。キャンセルの定義については議論があるけど、記事や私が聞いたほとんどの口語的な定義では、未来がポーリングされる前にドロップされることを指してる。これは非常にクリーンで、未来をキャンセルしたいなら、ただドロップすればいい。RustはRAIIを強く推奨しているから、クリーンアップはドロップ実装に入れることができる。キャンセルのもっと厳しい定義は「未来が二度とポーリングされない」というもので、これが記事で触れられていることだね。未来はドロップされないけど、そのポーリングも到達できないから、デッドロックが発生する。

用語が曖昧だから、質問に答えるのが難しいね(タスクとフューチャーの違い)。Rustには構造化された同時実行を実現する方法があるけど、それはタスク向けであってフューチャー向けじゃないんだ。実際には「アクティブフューチャー」っていう概念はあまりなくて、最後にポーリングしたときにPendingを返したフューチャーを「アクティブフューチャー」と呼ぶくらいかな。タスクは、いくつかのフューチャーをポーリングすることで進行を促すものなんだ。でも、そのフューチャーの中には、自分が作った他のフューチャーのポーリングを扱いたいものもあるから、そこが問題になる。記事にもあるように、すべてをタスクとしてスパンするのも一つの選択肢だけど、それだけじゃ全ての問題が解決するわけじゃないし、フューチャーを使う有用な方法も制限されちゃうんだよね。

10月だけで5本以上のマルチスレッドに関する記事やコメントを見たけど、なんでいつも「コードがロックするかアトミックを使うなら、それは間違ってる」って言ってたのか分からない。みんなが「間違ってる」って言うけど、記事に書かれているようなことが起きるんだよね。解決策を勧めたいけど、専門家でないとマルチスレッドを実装する合理的な方法はほとんどないと思う。ErlangやElixirが良いって聞いたけど、試したことがないからあまりコメントできない。

「コードがロックするかアトミックを使うなら、それは間違ってる」といつも言ってた。みんなが「間違ってる」と言うけど、記事に書かれているようなことが起きるんだ。 じゃあ、3Dの患者ボリュームを流れる高エネルギー光子(X線)をシミュレーションしてると仮定しよう。放射線がどのように分布しているかを正確に推定するために、20億の粒子が患者を通過するのをシミュレーションする必要がある。ロックやアトミックなしで、シミュレーションが100時間かからずにこれをどうやって達成するの?明らかに、1粒子ずつシミュレーションするのは永遠にかかるけど、ロックやアトミックなしでは、粒子が患者の放射線分布を更新する際にお互いの足を踏むことになる。患者のボリュームのメモリに20億のコピーを持って、それぞれの粒子が自分のプライベートコピーを持って、最後にそれをすべてマージするってこともできるかもしれないけど…。