async Rustの罠「Futurelock」 プログラマが気づかないうちにバグを生む危険な挙動
引用元:https://news.ycombinator.com/item?id=45774086
Oxideの制御プレーンでFuturelockってマジで厄介な問題にぶつかったんだって。
Async Cancellation問題に似てて、プログラマは正しく書いてるつもりなのにバグになるんだよ。
ベテランRustエンジニアでも見つけるのに時間かかったらしい、かなり奥深い問題だね。
詳しくはここ見て: [0] https://github.com/oxidecomputer/omicron/issues/9259
[1] https://rfd.shared.oxide.computer/rfd/397
[2] https://rfd.shared.oxide.computer/rfd/400
[3] https://www.youtube.com/watch?v=zrv5Cy1R7r4
このドキュメントは徹底的で透明だね。
Cancellation safetyを知らないせいで、Omicronにはたくさんバグがあるかもって。
C言語のメモリ安全問題みたいに、コンパイラも助けてくれず、デバッグも難しいし、最悪クラッシュやデータ破損に繋がるのが本当にイライラする。
これはRustのコア原則と真逆でしょ
みんな混乱してるかもだけど、前のAsync CancellationはFutureを’drop’する話で、デストラクタは実行される。
今回のFuturelockはFutureを’forget’したり長く持っちゃうと、デストラクタが実行されないって話。
どっちもRustの狭い’安全性’の定義ではOKだけど、Rustの哲学に期待してた人はガッカリだよね。
Rustチームも昔’leakpocalypse’ってやらかしてるし。
もしFutureが常にポーリングされることでプログラムの正しさを保証したいなら、Asyncタスクをspawnすべきだよ。
そうすればランタイムがポーリングしてくれるから、忘れる心配もない。
FutureはAsyncランタイムタスクより低レベルな抽象化なんだから。
Async Rustはまだ半熟で複雑すぎる気がするな。
アプリケーション開発なら、スレッドで十分でしょ。
スレッドは安価だし、Asyncほどごちゃごちゃしてないしね。
Async Rustの複雑さは仕方ない部分もあるけど、スレッドをデフォルトの並行処理 primitiveにすべきって意見にはマジで同意。
他の言語の経験からみんな誤解してるけど、Rustのスレッドはめちゃくちゃ良いからね。
スレッドじゃ足りない時にだけAsyncを使うべきだよ。
Asyncは概念的にはいいんだけど、人気のライブラリのほとんどがAsync使ってるから、避けるのが難しいんだよね。
Webサーバーとかリクエスト送信とか、人気のライブラリはだいたいAsyncだもん。
NomのAsync NATSクライアントが非推奨になったんだよね。
ほとんどのプロジェクトはAsyncが必要なほど大規模にならないし、Tokioを入れると移植性とかサプライチェーン攻撃のリスクが増えるから、そこは残念だよね。
’あらゆる非自明なプログラムは…’の精神で、使い捨てスクリプトより複雑なものにはAsyncを最初から選ぶのがアリかもね。
パフォーマンスとか同時接続数じゃなくて、プログラムが複雑な状態機械になりそうかどうかがポイントだよ。
この@sunshowersの投稿が参考になるよ: https://sunshowers.io/posts/nextest-and-tokio/
一番の問題は、適切なランタイムサポートなしでリリースされ、Async/awaitって言ったら今でもTokioって感じなことだね。
.NETもAsync/awaitをちゃんと整備するのに10年近くかかってるし、RustもAsyncトレイトとか、Pinのエルゴノミクスとか、Asyncラムダとか、まだまだ課題が多いよ。
同期と非同期のRustコンポーネントが混在するアプリを開発してるよ。特定のタスクでは、非同期の方がマジでシンプルになるんだ。
Rustはメモリ安全性とデータ競合の問題を解決してくれるから、以前と比べて大幅な改善だよね。でも、本当に正しさが保証されたソフトウェアを作りたいなら、現状は形式検証しかないんだよ。
それはデータ構造がプログラムの外部に公開されてない場合に限る話だよ。OSのIPCメカニズム(メモリマップドデータ、共有メモリセグメント、DMAバッファなど)で外部イベントによってアクセスされる場合、Rustはデータ競合からの安全性を保証できないからね。
もっと言えば、Rustを使えば誤用できないようなAPIをたくさん作れるんだ。これはRustだけじゃないけど、CやGoなんかと比べると、型にもっと多くの制約を組み込めるんだよ。
ちょっとした指摘だけど、形式検証も「正しさ」を完全に保証するわけじゃないんだ。
この問題を避ける高レベルの抽象化ってあるのかな?Rustの並行処理にはまだデッドロックの可能性があるよね?RAIIスタイルの何かで防げるはずなのに、この単純なケースで静的に問題を特定できないのはなぜだろう?これが偶然なのか、Rust/Tokioモデルの限界なのか、まだよくわからないんだ。
「この問題を避ける高レベルの抽象化ってあるのかな?」って質問だけど、Tokioの上にActorモデルを構築するっていう手があるよ。参考になる記事があるから見てみて:https://ryhl.io/blog/actors-with-tokio/
Actorモデルは大好きで、C++で6年以上使ってるんだ。でも、現実の問題を解決するには、Actorフレームワークに「ロック」を導入しないといけない場面があったんだよ。自作ライブラリなら簡単だけど、一部のサードパーティ製Actorライブラリは思想が強くて、純粋さを破る機能を追加してくれないから、実務では使えないんだよね。
リソースを所有してアクセスを制御する単一のActorだけでは解決できない、ロックが必要なシナリオってどんなの?
2つのActorをアトミックに更新しないといけないシナリオだよ。例えば、2つの口座間で送金する場合、増減する前に両方のActorをロックする必要があるんだ。現実世界では、他の並行トランザクションで口座が変わる可能性があるからね。ハンドシェイクはエラーになりやすい。Actorをロックして、クリティカルな処理をして、アンロックする。合理的な世界ならこれでうまくいくんだけど、偏見のある世界だと、Actorモデルでロックを使うことに開発者が反対するんだ。だから自分で作ったんだよ…。
ソフトリアルタイムとまではいかなくても、パフォーマンス重視のシナリオだと、そういう調整コードの実際のコストが問題になってくるかもしれないね。
でも、Rustだとアクター抽象化がかなりうまくコンパイルされてくれるかも!
それ面白いね。どんなアクターのユースケースで、アクターにロックを追加する必要があるんだろう?
そしたらデッドロックがライブロックに変わるだけで、AFAIKだけど根本的な問題は避けられないんじゃないかな。
Fuchsiaの人たちはトレイトシステムを使って、グローバルなミューテックスロック順序を強制してるんだ。それによって、2つのスレッドがお互いを待っているミューテックスをロックすることで起きるデッドロックを静的に防げるんだよ。
今回のケースには役立たないけど、もっと良い方法があるかもしれないって示唆してるよね。
そのコードのリンク、探してきてくれる?もっと詳しく知りたいな。
Rustの並行処理にはまだデッドロックの可能性があるって感じるんだけど、デッドロックが起こらない汎用的な計算モデルってあるのかな?
アクターみたいなものを使っても簡単にサイクルができちゃうと、ブロックするプリミティブが違う(CPUレベルじゃない)だけで、それをライブロックと呼ぶけど、根本的には同じだよね。
それはwithoutboatsさんのFuturesUnorderedに関する記事[0]で説明されてるデッドロックの、すごく巧妙なバージョンだね。
“タスク内”並行処理を使うときは、どのFutureもスタベーションしないように絶対に確認すべきだよ。
タスクをspawnするのがデフォルトであるべきだね。タイムアウトにはtokio::select!を使うけど、すべてのペンディング中のFutureがそれに所有されてることを確認してね。
FuturesUnorderedは、すべてのエッジケースを本当にテストしない限り、絶対おすすめしないよ。
[0] https://without.boats/blog/futures-unordered/
これって優先度逆転にすごく似てるね。例えば、高優先度スレッドT_highが低優先度スレッドT_lowが持ってるロックを欲しがると、T_lowがスケジュールされるまでT_highは動けない。
OSならこれを検知してT_lowにT_highの優先度を“継承”させられるんだけど、tokioでも似たようなことはできないのかな?
例えば、“動かせない”Futureが持ってるMutexを待ってたら、代わりにそのFutureをpollするとか。
“動かせない”ケースの検出はかなりオーバーヘッドかかるだろうけど、できないことはないかもね。
特に難しいのは、直接awaitを使わなくても、例のようにFuturelockが起きる場合があるってことだよね。
だから、“動かせない”検出器は、他のタスクがそのFutureを実行しないことと、そのFutureがこのタスクによってpollされているもののセットに含まれていないことを判断する必要があるんだ。
tokioで似たようなアイデアは可能か?っていう質問についてだけど、Tokioタスクなら意味があるかもね(スケジューラが複雑だから、もうやってるかもしれないけど)。
でも、この記事で言ってるような「タスク内のFuture」には無理だよ。
これはasync Rustの「Futureは不活性」って設計に起因してるんだ。
Futureを作ったりpollしたりやめたりするのに、ランタイムと必ずしも通信する必要はないんだ。
ランタイムと話すのは、新しいタスクをspawnするか、自分のタスクをwake upするときだけ。
Futureは普通のstructみたいなもんで、Tokioはasync関数が内部でいくつのFutureを作ってるかなんて、integerやstring、hash mapの数を知らないのと同じなんだよね。
もっとコメントを表示(1)
async Rustの「futures are inert」な設計は根本的に問題があるし、Rustらしくない設計だと感じるな。ユーザー空間で管理される不活性なFutureは、プログラマが思わぬミスをする原因になるって言ってるね。
好みと欠陥は違うと思うな。Rustは特定のターゲットでは不活性なFutureじゃないと動かないから、言語がそれらのターゲットを優先するのは全くもって正当だよ。
必須なのはFutureがヒープ割り当てされないことで、不活性であることじゃない。Rustの設計が唯一の解決策とは限らないと思うよ。初期のRustでは、イテレータを軽量コルーチンとして実装するために、スタックレイアウトを工夫してたみたいだね。このRedditの投稿を見てみて:https://old.reddit.com/r/ProgrammingLanguages/comments/141qm…
もしFutureが不活性じゃないなら、カーネルやマイクロコントローラでasyncをどう使うの?不活性じゃない実装は、標準ライブラリとコンパイラ内に単一のランタイム実装を前提としちゃうから、独自のディスパッチが必要な環境じゃ使えないよ。
Go出身の同僚も、Goランタイムみたいなデッドロック検出器がRustにないのはなぜって聞いてきたよ。Goはgoroutineが並行性の基本単位だから依存関係を推論しやすい。でもasync Rustでは、タスクが並列性の基本単位で、並行性はFutureの合成から生まれるんだ。Tokioスケジューラはタスクの内部を見れないから、この問題は検知できない。この問題を避けるには、同じタスク内のFutureじゃなくて、生成されたタスクを選択する方がいいって言われてるね。
別の話だけど、初期のRustにはトレース型ガベージコレクタもあったよね。その時に捨てられた設計決定が、今日の言語にどれくらい関連してるのかは、私には全然明らかじゃないな。
ありがとう。Rustの並列処理の概念を理解しようとするたびに、これがしっくりこなかったんだ。プログラムがブロックしないことを合理的に証明する方法をまだ読んだことがないよ。従来の軽量スレッドなら、すべてが割り込み可能で、エンジニアがタイムアウトを設定して、失敗を設計で対処する必要があるってイメージなんだけど、教材の簡単な例にはそういうのがないよね。Rustのasyncも安全に使うには、そんな注意深い設計が必要なのかもね。
これは重要だよ。イテレータとforループの本体を並行実行しながらヒープ割り当てを避けてるからね。asyncがやってることと全く同じ種類の話だよ。
Rustのasyncはカラーリングされたスタックレスコルーチンモデルだと思ってたから、以前実行していたasync関数の実行を再開するのは安全じゃないと感じてたよ。スタックレスコルーチンはローカル状態のためにスタックを共有するから、async関数の実行はLIFO順に進む必要があるんだ。だからカラーリングが必要なんだね。スタックフルコルーチンモデルとは違うんだ。
Erlang/ElixirやGoは、そもそも危険なやり方をさせないことでこの問題を“解決”してるんだ。これはすごく妥当な解決策だけど、唯一じゃない。Rustのasyncタスク管理は避けようとしてる、比較的高価な完全ロックの代償を払うことになるんだ。Rustはたくさんの小さなタスクを処理するとき、かなりのパフォーマンス向上を期待できるからね。すべての言語がこんなに細かく制御できる必要はないけど、必要なときにこれだけの制御をさせてくれる言語があるのは良いことだよ。CやC++だけにこの領域を明け渡さないのは良いことだ。残念ながら、こんな制御は“フットガン”(足元を撃つ銃)を伴うけどね。将来、誰かがRustでこの問題を解決する方法を見つけてくれるといいな。
「Erlang/Elixirは高価なフルロックの代償を払ってる」って言ってるけど、Erlang/Elixirの目的はできるだけ高性能であることだよ。Erlangの歴史がそれを証明してる。BEAMは素晴らしいし、すごく速いんだ。それに、その上で動く言語(OTPビヘイビア、スーパーバイザーなど)もすごくエルゴノミックだよ。
「不活性ではない」ってのが「std+コンパイラ内の単一ランタイム」を意味するわけじゃないよ。そこまで極端に飛躍しすぎだね。問題は、Rustがディスパッチ制御に選んだインターフェースの粒度が足りないことなんだ。自分でディスパッチするときは、個別のタスクにしかアクセスできなくて、個々のFutureについてはselect!やFuturesUnorderedみたいな、システムのごく一部しか見えてないコンビネータに頼りっぱなしになる。もっと良い設計は、ヒープ割り当てを避けつつ、個別に停止したリーフFutureの観点から操作できるようにして、自分でディスパッチできるようにすることだろうね。join!/select!/などといったコンビネータは、サブタスクの完了を待つようなスレッドベースのシステムみたいに実装されるべきだよ。
カーネルやマイクロコントローラのユースケースは過大評価されてると思うな。いくつかのベアメタルプロジェクトではスタックレスコルーチン(厳密には再開可能な関数)を使って並行処理をしてるけど、予想してたよりずっと小さなユースケースだったね。実際、CやC++のコルーチンは使う苦労に見合わないし、RustのasyncはTokioみたいなヘビーデューティなexecutorと一緒に使われてるのがほとんどで、小さな#[no-std]な16ビットマイクロコントローラ向けじゃない。カーネルはバックグラウンド作業に再開可能な関数を使わず、カーネルスレッドを使うんだ。広い組込みの世界ではスレッドの方がみんなが思ってるよりはるかに一般的だし、本当にローエンドな単一プロセッサシステムはブロックしても大丈夫なことが多い。こういう小さなシステムは、毎秒何十ものI/Oでブロックするリクエストを処理してるわけじゃないから、コルーチンからそこまで恩恵を受けないんだよ。Rustの大きなプロジェクトでasyncが使われるのは、I/O(ネットワーク、FSなど)でブロックする同時リクエストを処理しなきゃいけない場合がほとんどで、エコシステムはTokioに収束しつつあるね。スレッドはタダじゃないけど、現代のほとんどの組込みプロジェクトで並列リクエストを処理するものは、カーネルも含めてすでにスレッドを使ってる。EagerなFutureはLazyなFutureより高価だけど、スレッドよりは安価で、面白い中間点だね。LazyなFutureは実行時コストがめちゃくちゃ安い。でも、その引き換えに莫大な複雑さのコストを払ってるんだ。それはごく一部のユーザーにしか利益がないのに、期待したほど実現してないんだよね。
Rustってメモリ安全性重視で、並行処理はErlangの方が得意なのかな?メモリ安全で、並行処理にも強い言語ってなんでないんだろう?OcamlとErlangを合わせたようなのが欲しいな。
ヒープ割り当てを避ける状況もあるけど、原理的にはスタックフルコルーチンでも全く同じ最適化ができるはずだよ。C言語だって、今でも配列をスタックに確保してpthread_createに新しいスレッドのスタックとして渡せるしね。大きすぎる割り当てを避けるには、正確にどれくらいのスタックが必要かを知る必要があるけど、これはRustコンパイラがasync/awaitで既に必要としてる知識と全く同じだ。みんなが気にしてるのはセマンティクスだよ。async/awaitは実装詳細を漏らしてる。Rustが今のやり方をしている理由の一つは、実装が例えばLLVMからのサポートを必要とせず、現在の実装が提供する利点を失うことなくasyncをより深く統合するために、何らかの機能作業が必要になるかもしれないからだ。Rustにはいくつかこんな厄介なところがあって、実装作業を高レベルのRustコンパイラに閉じ込めるために、セマンティクスが歪められてるんだ。
「予想してたよりずっと小さなユースケースだった」って言ってるけど、いやいや、Rustのasync MVPを設計した当時から、ユーザーの大多数はウェブサーバーを書くだろうって、みんなかなりわかってたよ。組込みのユースケースは、もし存在したとしても、明らかに少数派だろうってね。Embassyが存在して、そのエコシステムがこれだけ活発なのは、むしろ予想外の勝利だよ。でも、実際にどれだけの人が使うと予想されてたかに関わらず、根底にある哲学はこうだったんだ。Rust言語の機能には、no_std環境と互換性のないものは存在しない(例えば、Rustはそんな制約がある中でクロージャのようなものが動くようにするために、わざわざ手間をかけてたくさんの複雑さを導入してる)。asyncに関してこの原則を破るのは、例外中の例外で前例がないことだっただろうね。
eager dispatchを使うなら、標準ライブラリにexecutorが組み込まれて、プロセスごとに単一のランタイムに限定されちゃうのはどうすればいいのか、ぜひ教えてほしいな。作成時に言語がFutureのディスパッチをスケジューリングする必要があるからね。これは、プラグイン可能なexecutorの取り組みの主な課題の一つなんだ。書けるexecutorの種類があまりにも違う(ワークスチール型とコアごとのスレッド型など)から、エフェクトシステムがないと統合不可能だし、あったとしても、executorは実行時に決定されるグローバルなものなのに、与えられたコードがどのexecutorに実際にディスパッチされるかわからないっていう課題もあるからね。良くも悪くも、eager dispatchは一般的にFutureのキャンセルができないことも意味すると思うよ。所有権がコードではなくexecutorに移っちゃうからね。
これは昔の神話だよ。BEAMだけが何千もの“プロセス”をパフォーマンスを落とさずに扱えた頃の話で、当時でさえ、BEAMは何千ものプロセスを扱えても、個々のプロセスはそんなに速くないってことをみんな見落としてたんだ。つまり、BEAMの極めて高いパフォーマンスは、ごく一部のことだけで、全体的に高性能だったわけじゃないんだよ。今ではBEAM以外にもたくさんのプロセスを扱えるランタイムはあるけど、BEAMは依然として比較的遅いVMだね。俺の経験ではCの10倍は遅いと思うから、せいぜい中程度のパフォーマンスVMで、Gleamみたいな良い言語では抽象化レイヤーを注意深く見ないと、乗算的な速度低下が本当に効いてくる。俺が最初に真剣に書いたGoプログラムはErlangで書かれたものの置き換えだったんだけど、書き換えでアーキテクチャ的に大きく改善したわけじゃないのに(元々そこそこ良く設計されてた)、デプロイ初日から、負荷スパイクで時々苦戦してた4システムから、1システムだけで全部処理できるようになったんだ。BEAMが10年以上成熟していて、Goのクラスタリングコードは俺が数週間で書いたものだったのにね。BEAMは並行処理の管理は得意だけど、他の点では遅いんだ。Pythonみたいな動的なスクリプト言語よりはずっとマシだけど、現代のコンパイル言語と比べるとパフォーマンス競争力はないよ。
RustのFutureは“ただの”structで、poll()メソッドを持ってるだけなんだ。poll()メソッドは他の関数と同じように、スタックにローカル変数を持てるけど、呼び出し間で保存したいものは全部スタックローカルじゃなくてstructのフィールドにする必要がある。async/awaitの魔法は、コンパイラがasync関数のどの変数がそのstructのフィールドになるべきかを判断して、そのstructとpollメソッドを自動で生成してくれることなんだよ。もしよかったら、俺のブログシリーズで具体的な詳細を解説してるから見てみて: https://jacko.io/async_intro.html
Executorなんていらないし、キャンセルも維持できるんだぜ。協調的マルチタスクシステムなら、新しいタスクをスタック領域を与えて、中断点まで実行すればいい。中断したらlockみたいなAPIがスタックを保存して、再開時にまた動かすんだ。RustのWakerはスタックの所有権とスケジューリングの単位を混同してて、誤ったウェイクアップを引き起こす可能性があるから、これらは切り離すべきだな。
必要なスタックサイズを正確に知るには、呼び出す関数が再入可能じゃないか、補助スタックを使う必要があるんだ。これはLLVMに限らず基本的な制約で、結局は「どの色の関数を使う?」っていう世界に戻っちゃうんだよな。
stackful coroutinesでも同じ最適化はできるって意見に大賛成!RustがLLVMのサポートを避けてる現状は、ただの想像力不足だよ。LLVMからはtail callsさえあれば、Futureのレイアウトと同じようにスタックレイアウトを自分で管理できるんだからさ。
なるほどね。Rustの実装は、呼び出される関数のスタックフレーム全部を事前に展開しちゃって、それが以前実行してたasync関数を継続できるようにしてるってことか。
“以前実行してたasync関数の継続はunsafe”ってのは、ちょっと違うな。Futureは何度でもpollできるし、async fnが状態機械になると、yieldはpoll fnがNotReadyを返すことで表現されるんだ。だから、少しだけ作業が進むことは可能だよ。Wakerの仕組みで、通常は作業がある時だけpollされるけどね。
以前の印象は間違ってたかも。C++の経験から、この哲学はわかるけど、厳密すぎるのもどうかと思うな。Rustもfreestanding環境ではC++みたいにunwindingとかをオプトアウトするし。Rustがembeddedに良いのは確かだけど、俺はtokioみたいなランタイムでasyncを使うのが好き。no-stdと互換性のない言語機能があっても、組み込み向けへの道が閉じるわけじゃないと思うんだ。
言語設計では、低レベルな制御がないことも重要なトレードオフになるって、多くの人が見落としがちだよね。だから、一つの言語で全ての領域をカバーしようとするのは、あまり実りがないかもな。まあ、個人的にはsync Rustは大好きだけど、Goはエレガントな言語じゃないと思うけどね。
Rustはメモリ安全もだけど、並行性のために作られた言語だよ。データ競合を静的に防ぐ産業レベルの言語なんて、他にそうそう見つからないさ。Erlangを使えないなら、並行処理にはRustより良い選択肢はないだろうね。
Gleamを探してる?BEAMやJavaScript向けの、シンプルだけど強力な型付き関数型言語だよ。Ocamlと比べるとランタイムが厚くて、マシンコードからは少し遠いけどね。でも、個人的には言語デザインがめちゃくちゃ綺麗だと思うし、Haskellで感じるようなtypelevel brainfuck問題もうまく避けてるんだ。チェックしてみて!
https://gleam.run/
async Rustで開発してた時の経験だと、トレードオフがあったな。参照でselectできなければ問題は起きないけど、そうするとループ内でselect!を使って同じロックを複数回取ろうとすると、キューのポジションを失うんだ。キャンセル問題も含め、これはベテランのRustエンジニアでも驚くような問題を引き起こすのは同感。Futureがドロップ可能なRustのモデルで何ができるかは不明だけど、コールバックと比べたら驚くことは少ないよな!
FuturelockはTokioの各部品が正しく動いてるのに、なぜか相互作用でデッドロックが起きるって話なんだね。&mut futureをselect!で禁止すれば防げるけど、まともなコードもダメになるから無理だと。誰のせいでもないし、良い設計の部品が組み合わさって偶然起きる「どうしようもない」挙動なんだってさ。非同期Rust全体の問題じゃなく、気を付けるしかないってのは悲しいな。
[1] https://news.ycombinator.com/item?id=45776868
もっとコメントを表示(2)
Tokioのselectは強力すぎるんじゃないかな。全部やめるんじゃなくて、この問題を起こさないような、もっと機能が限定された仕組みを作ればいいんじゃない?そうすれば、そんなに強力な機能がいらない場所で、バグのリスクを減らせるはずだよ。フルパワーの仕組みをなくす必要はないけど、それをオプションにすればいいんだ。
select!()って、タイムアウトみたいなよくある使い方とすごく近いから、弱くするのってかなり難しいと思うんだよな。安全にするにはランタイムチェックとかアロケーションが必要になりそうだけど(俺の想像力が足りないだけかもだけど!)、そうなるとasyncの基本的な部品が安全になるために、もっとコストの高いチェックやアロケーションが必要になるのはちょっと嫌だな。
デザインの余地はまだまだたくさんある気がするんだよね。もちろん、”選択”を基本的なビルディングブロックと見なすこともできるけど、それだけじゃ不十分だと思うな。JavaScriptがPromise.anyとかPromise.allとかPromise.allSettledとかPromise.raceとか、いろいろ提供してるのには理由があるんだ。選択ってのは単一のビルディングブロックじゃなくて、それぞれ異なる意味を持つビルディングブロックのファミリー全体なんだよ。
クリティカルセクションの中では、必ず処理が進むことを保証しなきゃいけないし、クリティカルセクションは確実に終わるようにしろよ。これ、そんなに理解しにくいか?俺から見たら、この状況はほぼ確実に行き詰まるようにできてたね。
シングルスレッドで再入不可能なロックを二重に取得するデッドロックと、仮想スレッドがクリティカルセクションの中で別のスレッドのコードを呼ぶデッドロックは、根本的に同じ問題で、同じタイプのデッドロックなんだ。
いや、select!()が、選ばれなかったOwned Futureを捨てずに返せばいいだけだよ。そうすれば状態は失われない。うん、これはちょっと不格好だけど、論理的にはこれしかないんだ。きっとマクロの魔術で使いやすくできるだろうけどね。でもこれでも根本原因は直らないよ、だってOwned Futureを捨ててもきれいにキャンセルされる保証はないからね。本当の根本原因はこれ見てくれ: https://news.ycombinator.com/item?id=45777234
それがどうやってデッドロックを防ぐんだ?もし、そのOwned FutureがMutexを握ってて、selectからそれが返されてまたポーリングされるかもしれないって、ユーザーが変数に代入したら、Mutexを握ったまま完了してないFutureがドロップされないままだろ。これって&mut futureをポーリングするのと、もっと手順が増えるだけで同じことじゃないか?
だから言ったろ、デッドロックは防げないって!Futureのドロップがちゃんとキャンセルされる保証はないんだからな。あれは、「select!をwhileループで使って、キューの中の位置を失いたくない」って時に使うんだよ。根本原因を直したいなら、https://news.ycombinator.com/item?id=45777234 を見てくれ。
Rustのデザイナーがもしここにいたら聞きたいんだけど、なんでActorパターンじゃなくてasyncパターンを選んだの?Actorパターンの方が、俺にはずっとクリーンだし、間違いも起こしにくいように見えるんだけど。Erlangを使い始めてから、それまでソケットとか非同期ワーカーで苦労してたのに、Actorモデルが全部解決してくれたって感じだったんだ。
Rustの設計者じゃないけど、Rustのasyncデザインの大きな動機の一つは、組み込みで動かすことだったらしいよ。つまり、mallocもスレッドもなしで、ってことね。これじゃあ、JSやC#、GoみたいなアクティブFutureとか、Actorモデルみたいな設計のほとんどは無理になっちゃうんだ。TokioでActorモデルを書くことはできるけど、それは自然なやり方じゃないんだよね。
システムプログラミングって組み込み、高性能、異言語連携の3つが絡み合ってるよね。Rust/C/C++が役立つのは、資源を食わないし、ランタイムに依存しないから。async Rustはこれら全部を狙ってるのかな?システムプログラミングを深く掘ると、これらが一つにまとまって見えてくる気がする。
そう、async Rustは全部だよ。組み込みで動くためにアロケーションなし、高性能のために仮想ディスパッチなし、FFIとの連携のためにスタックの変な挙動なし、が求められたんだ。これらの制約とRustのメモリ安全性とかを合わせると、今の設計以外に選択肢はほとんどないって感じ。リニア型は状況を改善できるかもね。
async Rustの利点の一つはパフォーマンスだね。アクター間でメッセージをコピーしまくるのは高コストになることも。でも、全てのプログラムでインライン化された最適化されたasyncステートマシンが必須ってわけじゃない。パフォーマンスが重要なところはasync、それ以外はアクターやチャネルみたいに使い分けるのが合理的だよ。
え、本当にメッセージはコピーしないといけないの?
俺の意見だと、async Rustの大きな設計ミスは、失敗の処理を強制しなかったことだよ。await(DEADLINE)みたいに、成功か失敗かをプログラマーが必ずハンドリングしないといけないシンプルなインターフェースにすべきだった。期限が来たら空の結果を返すってのを、言語側で決めるべきだったんだ。
アクターが別々のコルーチンやスレッドで動くなら、データが送られた後に変更されないようにする必要があるんだ。シングルスレッドならデータ競合は防げるけど、論理的な競合は残る。本当に並列に動かすとか、論理的な競合を無くしたいなら、データはメッセージにコピーするか、ロックで囲む必要がある。だから、ほとんどの状況でデータコピーは避けられないね。
Rust asyncって結局ネイティブスタックを使ってるけど、これはLIFO順のメモリ割り当てだよね。組み込みだとスタックの制御ってすごく大事。システムアロケータに頼らないのと同じくらいさ。だからRust asyncの設計が明示的なアロケーションを避けることにこだわったのに、組み込みが使えるような明示的なアロケータ(事前割り当てや再利用ができるやつ)を使わなかったのは残念だなぁ。
この動画とブログ記事を見るのがおすすめだよ。https://www.infoq.com/presentations/rust-2019/ https://tokio.rs/blog/2020-04-preemption
アクターはグリーン・スレッドみたいだけど、RustはCとのFFIのオーバーヘッドを避けるために選ばなかったんだ。async/awaitはステートマシンにコンパイルされるからオーバーヘッドが低い。GoやErlangとは違うトレードオフで、Rustはゼロコスト抽象化を目指すシステムプログラミング言語なんだよ。Goも昔はプリエンプションがなくて問題があったし、Erlangは予算制でスケジュールするんだ。
モノトニッククロックの値を得るのに、たくさんシステムコールが必要になるの?他にやり方はないのかな?
Zigのasync実装がどうなるか、興味津々だね。先行者の失敗から学べるのは強みだろう。
Rustに戻ると、アクターモデルの方がやっぱり優れてると思うんだ。ゼロランタイムアロケーションも可能だけど、それには制約を受け入れなきゃいけない。
asyncって見た目はシンプルだけど、その裏は複雑。アクターモデルの方が、最初はややこしく見えても、結局は考えやすいんじゃないかな。
Rustはムーブセマンティクスでコンパイル時に問題を解決するから、実行時のオーバーヘッドはないよ。これがRustの存在意義とも言えるし、すごく便利だよね。
Erlangでは予算が’reductions’で計られるんだ。BEAMがCPUみたいに動いて、割り込みなしでプロセスをプリエンプションしてるように見せるんだよ。Goはマシンコードにコンパイルされるから、プリエンプションが実装されるまではyieldとかフックが必要だったんだ。CPU自体にこういうクォータ管理機能がないのが不思議だよね。ハードウェア割り込みに頼ってるけど、あれは気まぐれだしね。
「不自然だって?」いや、俺はasync Rustのほとんどをアクターモデルに沿って書いてるけど、自然だと感じるよ。Tokioの著名な貢献者Alice Rhylもこのパターンについて書いてるしね。詳しくはこちら: https://ryhl.io/blog/actors-with-tokio/
これには俺も驚いたよ。趣味の組み込みとかHTTPサーバーのOSSエコシステムがAsyncにコミットしてるのは知ってたけど、Oxideまでそうだとは思わなかったな。
スタックのアロケーション/デロケーションはメモリを断片化させないから、組み込みシステムにとってはすごく大きな違いだよ。ヒープを避ける主な理由でもあるんだ。
スタックでもメモリは断片化する可能性があるんだ。例えばスタック上に10個のFutureを作って、最後の1つが一番最後に完了したとするじゃん?そしたら最初の9個分のメモリは最後のFutureが完了するまで解放されないんだよ。これは、同じくらいのサイズのものを同じサイズのセルに割り当てるカスタムアロケーターを使えば起きない問題だね。
なんでアクターモデルがmallocやスレッドを必要とするんだろうね?
実は、俺たちのシステムの組み込み部分ではRust asyncは使ってないんだ。ファームウェアがマルチタスクのマイクロカーネルOS、Hubris[1]がベースで、OSのスケジューラーレベルで並行性を表現できるからさ。サービスプロセッサはシングルコアだけど、OSで複数のスレッドをスケジュールできるんだ。でもね、プリエンプティブなマルチタスクOSがないシングルコア組み込みシステムではRust asyncはすごく便利だよ。OSがなくてもイベント駆動で複数のことを並行でやれる方法を提供してくれるからね。[1] https://hubris.oxide.computer/
「ネイティブスタックはただのメモリ確保器」って言うけど、その「ただの」にはすごく重みがあるんだよ。ハードウェアスタックは、メモリへのアクセスを提供すること以外はヒープメモリ確保器とはあらゆる点で違うんだ。大量の組み込みコードがスタックをハードウェアスタックだと前提にしてる。そのコードを「ヒープと同じAPIを持つダミー/静的アロケーターを使うだけ」にするのは全然簡単じゃない。そのコードはRustじゃないかもしれないし、組み込みコードではアロケーターの前に抽象化がないのが普通だよ。特定のコンパイラとハードウェアの組み合わせ、特定のスタックメモリ管理スキーム向けに書かれてるんだから、そうしない理由はないよね。それは特定のデバイスドライバーがデバイスに依存しない抽象化を使わないことに文句を言うようなものだよ。
「気まぐれ」だって?教えてくれよ、OSがスレッドを切り替えるとき、どういう風に「気まぐれ」さが出るんだい?