4 ポイント 投稿者 GN⁺ 2024-03-26 | 1件のコメント | WhatsAppで共有
  • Rust の async/await はスレッドの単純な代替ではなく、I/O 中心の並行コードを 合成可能な状態機械 として表現するプログラミングモデルである
  • Web サーバーのように複数の接続を同時に扱うコードは、線形実行だけではすぐに限界が生じるが、スレッドは thread::spawn によってクライアント処理を分離し、同時処理 を可能にする
  • async/awaitawait の地点で実行を譲り、エグゼキュータがほかのタスクを続けて実行できるようにすることで、多数のタスクを 1 つのランタイム内で交互に実行する
  • 3 秒のタイムアウトのような要件は、async では raceTimer の組み合わせで付加できるが、同期スレッドコードでは TcpStream 専用のラッパーと読み書きタイムアウト設定が必要になり、汎用性 が低下する
  • 性能オーバーヘッドだけで async を説明すると CPU bound な処理で反例が生じるため、Rust の async の強みは 意味論的な表現力 とエコシステムの合成可能性にある

Rust 並行性の出発点

  • Rust の通常のコードは基本的に線形実行構造である
    • foo(), bar(), baz() のように、1 つの処理が終わってから次の処理が実行される
  • Web サーバーのように複数の仕事を同時に処理しなければならない場合、線形構造はすぐに限界へ達する
    • TcpListener::accept() でクライアントを受け取り、handle_client() を実行する構造では、最初のクライアントを処理している間に 2 番目のクライアントは待たされる
    • handle_client() に数ミリ秒かかり、同時クライアントが 2 人なら短い待ち時間で済む
    • 同時クライアントが 200 万人なら、キューの末尾のユーザーは数分待たされる可能性がある

スレッドが解決する方法

  • OS スレッドはレジスタ値とプログラムスタックをメモリに保存し、別のルーチンを実行した後で、あとから元のルーチンを再開できる
  • Web サーバーのコードは thread::spawn(move || handle_client(client)) の形でクライアント処理を別スレッドに任せる
    • メインスレッドは新しい接続を継続して accept() する
    • クライアント処理スレッドがブロックされると、OS はメインスレッドに戻って次の接続を受け付けられる
    • 2 つのクライアントは数マイクロ秒程度の遅延の後、並列に実行できる
  • 本番運用の Web サーバーが数十個の CPU コアを持つ場合、OS はスレッド群を同時に動いているように見せるだけでなく、実際に複数スレッドを同時実行できる

async/await の動作方式

  • ユーザー空間の並行性にはイベント駆動プログラミング、アクター、コルーチンなど複数のモデルがあり、Rust が選んだ方式は async/await である
  • 単純化して言えば、プログラムは互いに独立して実行可能な状態機械の集まりとしてコンパイルされる
    • async fn は従来の関数ではなく、状態機械を返す関数である
    • await は別の状態機械を現在の状態機械の一部の段階として取り込む
    • 内部関数が新しい接続待ちのように実行を譲ると、全体の状態機械は上位のエグゼキュータに制御を返す
  • smol::Executor のようなエグゼキュータは、現在の状態機械の代わりに spawn で生成された別の状態機械を実行する
    • async move { handle_client(client).await } ブロックは main と独立した新しい状態機械である
    • main が譲るとクライアント作業の 1 つが実行され、その作業が再び譲ると次の作業へ巡回する
  • この構造により数百万のクライアントを同時に扱えるが、エグゼキュータ、タスク、状態機械といった概念が加わるため複雑さも増す

タイムアウト例に現れる合成可能性

  • Rust の強みの 1 つは 合成可能性 である
    • Iterator はさまざまなコンビネータをつなげられ、その結果を再び Iterator を受け取る関数へ渡せる
    • mpsc::channel()recv.try_iter().filter(...).map(...) のように、値をフィルタリングして変換し、リストへ追加できる
  • async/await はこの合成可能性を I/O bound な関数にも適用できるようにする
  • handle_client()read_to_end, do_something_with_data, write_allawait する非同期関数なら、3 秒タイムアウトは 2 つの Future を組み合わせて実装できる
    • race は 2 つの Future を同時に実行する
    • Timer は指定時間の経過後に返る
    • 既存の接続処理コードを async ブロックで包み、3 秒後に TimedOut エラーを返す Future と競争させればよい
  • この方法は TcpStream だけに縛られない
    • impl AsyncRead + AsyncWrite を実装する対象なら同じパターンを適用できる
    • 一般的なストリーム上の GZIP ストリーム、Unix ソケット、ファイルのような対象にも置き換え可能である

同期スレッドコードで同じタイムアウトを実装する際の制約

  • ブロッキングコードでは一般に readwrite のシステムコールを中断しにくく、ファイルディスクリプタを閉じるといった方法は Rust では使えない
  • TcpStreamset_read_timeoutset_write_timeout を提供する
    • 読み取りと書き込みそれぞれにタイムアウトを設定できる
    • ただしクライアントが 2.9 秒ごとに 1 バイトずつ送ると、単純なタイムアウトは継続的にリセットされ得る
  • これを防ぐには、TcpStream を包む DeadlineStream のような型を作り、全体のデッドラインまでの残り時間を毎回計算して、読み書きタイムアウトに設定しなければならない
  • このアプローチは機能し得るが、制約が大きい
    • TcpStream に縛られる
    • Rust には set_read_timeoutset_write_timeout の利用を抽象化する trait がない
    • 汎用 writer に適用するには追加作業が多く必要になる
    • タイムアウト設定のための追加システムコールが発生する
    • 実際の Web サーバーロジックでは利用がさらに煩雑になり得る

Rust async エコシステムの事例

  • HTTP エコシステムがクライアント側まで含めて async/await を主要なランタイム機構として採用した背景には、関数の合成可能性がある
    • HTTP 呼び出しを作る関数を、さまざまな隙間やユースケースに合わせて差し込める
  • towerasync/await の合成可能性を示す代表例である
    • サービスを async 関数として実装すれば、タイムアウト、レート制限、ロードバランシング、hedging、バックプレッシャー処理を追加できる
    • どのランタイムを使うか、サービス内部で何をしているかに関係なく tower を適用して堅牢性を高められる
  • macroquad は Rust 向けの小型ゲームエンジンで、メイン関数に async/await を使ってエンジンを実行する
    • Rust で何らかの処理を待つために線形関数を止めなければならない状況を表現するのに async/await は適している
    • 同じスレッド上でゲームサーバーのネットワーク接続と GUI フレームワークを同時にポーリングするといった構成が可能になる

性能だけで async を説明する際の限界

  • Rust Async Book は、OS スレッドはプログラミングモデルを変えずに並行性を表現しやすい一方、スレッド間同期が難しく、性能オーバーヘッドが大きく、スレッドプールでも大規模な I/O bound ワークロードを十分に支えにくいと比較している
  • async コミュニティでは、なぜ OS スレッドではなく async を使うのかと問われたときに、「オーバーヘッドが低く、あとは同じだ」といった形で答える傾向がある
  • Web サーバー作者たちが async/await へ移行した理由は C10k problem を解決するためだったが、すべての利用者にとって async/await を選ぶ理由が性能である必要はない
  • 性能上の利点は状況によって消えることがある
    • CPU bound な処理では、同等の async ワークフローよりスレッドベースのワークフローのほうが速い場合がある
    • Rust async の一時的な性能優位は過度に強調され、意味論的な利点は過小評価されてきた
  • async/await はニッチなケース向けの道具ではなく、同期 Rust では数十本のスレッドとチャネルなしには簡潔に表現しにくいパターンを扱う強力なプログラミングモデルである

sync Rust のようにするのではなく違いを受け入れる

  • Rust プロジェクトのロードマップには、async Rust の記述は、ときどき asyncawait キーワードを使うことを除けば、同期コードを書くのと同じくらい簡単であるべきだという方向性がある
  • しかし async Rust を「sync Rust とまったく同じ」にするというフレーミング自体が本質的に難しいという見方もある
    • 99% まで似せられても、平均的な利用者は違いに気づかざるを得ない
  • Rust の async/await エコシステムは同期 Rust と同じになろうとするより、合成可能性表現力 という強みをより明確に打ち出すべきである
  • 並行性が必要なときに async/await が基本の選択肢になるようにするには、技術的な性能上の理由より、意味論的な理由でこのモデルを説明すべきである

1件のコメント

 
GN⁺ 2024-03-26
Hacker Newsの意見
  • シングルスレッド async/await は単純でよく知られたモデルであり、JavaScript がその方式。
    スレッドは複数の CPU を問題解決に投入でき、Rust はロック管理を助けてくれる。優先度の異なるスレッドも持てるため、計算バウンドな処理では必要になることがある。
    一方で マルチスレッド async/await は厄介になる。計算バウンドな区間が本格的に入ってくると、他のタスクと共有しているスレッドを事実上塞いでしまうため、モデルが崩れやすい。
    Rust の計算バウンドなマルチスレッドは、期待するほどうまく動かない。アロケータで複数スレッドが同じロックを叩いて futex 輻輳崩壊 が起きることがあり、とくにバッファ拡張中にアロケータ全体をロックしたまま再コピーが発生すると非常に高コストになる。Wine の Microsoft ライブラリエミュレーション .DLL 内のライブラリアロケータはこの問題に弱く、CPU 時間がスピンロックにほぼ吸われて性能が二桁倍悪化したが、Microsoft 実装ではそうではなかった。
    また標準の Mutexcrossbeam-channel チャネルでは 不公平な mutex の飢餓 が起きることがある。複数スレッドが資源をロックして処理し、解放してを繰り返すと、1 本のスレッドが勝ち続けて残りが締め出されることがある。公平な mutex が必要なら parking-lot があるが、標準 mutex が提供する、スレッドパニック時の poisoning による安全性はない。
    I/O バウンドでないなら、はるかに複雑になる。
    https://users.rust-lang.org/t/mutex-starvation/89080

    • その通り。主に I/O バウンドな計算だけを扱ってきたが、そこでも 競合問題 は起きる。
      I/O スループットが再び限界なら、100 万個のコルーチンに何の意味があるのかと思う。サイズ 10 の DB 接続プールをすぐ枯渇させるなら、コルーチンは助けにならず、デバッグや回避策をより難しくし、推論しづらくするだけだ。
    • この問題は結局、ハードウェアレベルで考え直す必要があるのかもしれないと思う。
      CPU バウンドな問題は体系的な割り込み/再開に行き着くように見えるし、進行中の実行スレッド n 個に対して公平かつ効率的なキュー型コンテキストスイッチが可能なら、たとえば進行中コンテキストを n 個持つ CPU があれば、問題は 資源配分 の問題になるのではないかと気になる。
    • 協調的マルチタスク の難しさが、なぜ何度も再発見されるのかわからない。
      責任ある設計の言語と見なされる Go ですら、最初は協調的に進めていたが、結局はプリエンプティブに変えざるを得なかった。協調的マルチタスクが役に立たないという意味ではないが、警告ラベルは付けるべきで、さらに言えば特定種類のコードは静的に実行を禁止した方がよいかもしれない。
      関連記事として「What color is your function」を置いておく。
      https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
    • これは 実装詳細 なのだと繰り返し言いたくなる。
      async/await のマルチスレッド実行器は飢餓を十分に処理できるし、.NET 実装はブロッキング呼び出しと非同期を混在させた非常に悪いコードでも耐えられる。
      https://news.ycombinator.com/item?id=39530435
      https://news.ycombinator.com/item?id=39786142
      https://news.ycombinator.com/item?id=39721626
    • async/await が生み出す 色付き関数 によって、ソフトウェア開発と保守のコストも増える。
      https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
      高いスケーラビリティが必要なソフトウェアでなければ、async のトレードオフはそれに見合わないかもしれない。
  • async/await とスレッドの論争の核心は、どちらがより複雑かではなく、エコシステムを二分し、片方が二級市民になって、プロジェクトで選択を誤ると摩擦が生じることにある。
    混在させて使うことはできるが、必要なときにはハッキーで非効率的になる。現在の Rust エコシステムは、I/O が入ると事実上ほぼすべてが async/await エコシステムに縛られるように決まっており、Rust でやろうとすることのほとんどは、まれな例外を除いて I/O を含むため、アプリケーションの残りが async を望むかどうかにかかわらず、非同期でないライブラリはたいてい無視せざるを得ない。
    Rust が async/await よりも組み合わせやすい抽象化を採用し、その組み合わせ可能性が他のものまで async/await 化することを要求しなかったなら、不満の大半は消えていたように思う。

    • その診断には同意する。自分の Rust async の記事[0]でも同じ結論に達した。
      さらに悪いのは、エコシステムが二つに分断されるだけでなく、async コードの内部でも通常は実行ランタイム、たいてい Tokio に強く結びつくことだ。関数の色問題を拡張すると、青(非 I/O)、緑(ブロッキング I/O)、赤(async I/O)ではなく、実際には青、緑、赤(Tokio)、紫(async-std)、橙(smol)のようになる。
      この問題に対しては、sans-I/O パターンが最良の解決策だと思う。すべての青いコードを分離し、I/O と時間には制御の反転を使えば、中核となるプロトコルロジックが I/O を知らない形にでき、さまざまな I/O 形態で包みやすくなる。
      0: https://hugotunius.se/2024/03/08/on-async-rust.html
    • 選択可能な二つのライブラリがあり、他の条件が同じである場合にだけ、非同期でないライブラリを無視すればよい。そういうケースはまれだ。
      async アプリケーションでブロッキングコードを使うのは、期待するほど滑らかではないが、難しくもない。foo() の代わりに tokio::spawn_blocking(foo).await を使えば、新しいコードを別スレッドで実行し、そのスレッドが終了したら完了する future を返してくれる。
    • C# も基本的には似ている。
      I/O に非同期でない選択肢はあるが、非同期の選択肢を使うと、事実上 Main() まで全部 async にすることを強いられる。同期メソッドから async メソッドを安全に呼ぶ方法もあるが、デバッグを極端に難しくする。
  • 記事では抜け落ちている部分が多い。
    async/await は 1 スレッドの文脈で実行されるのでロックや同期が不要だが、CPU コアを活用しようとして複数スレッドで async/await を実行すると、ロックと同期が再び必要になる。この複雑さは外部コードに隠せることがある。たとえば単一の DB 接続へのアクセスを同期する代わりに、async タスクごとに DB 接続を 1 本ずつ開くほうが簡単だが、SQLite や PostgreSQL では性能に影響する可能性がある。
    async/await では エラー伝播 が明確ではない。特に async タスクを束ねようとするときがそうで、Happy Eyeballs が典型例だ。
    ネットワーク I/O を語るなら、バックプレッシャー も一緒に扱うべきだ。CPython の async/await 実装はネットワークのバックプレッシャーが不足していることで悪名高く、それに起因する問題がある。

    • async/await にはいろいろ問題があるが、最大の不満はこれだ。
      Gang of Four の “Design Patterns” は、もともと C++ の欠陥を回避するためのレシピ本のようなものだったのに、人々はそうした欠陥のない言語にまでそのパターンを適用した。
      Rust は JavaScript ではなく、複数スレッドをうまく実行できる。だから async/await を必須にする必要はなく、システム言語らしく別の解決策を試せたはずだ。
      しかし JavaScript プログラマに Rust を売り込むには async/await が必要だった。without.boats は「Rust の生存がこの機能にかかっているという前提のもと、誠実な熱意で async/await を推し進めた」と書いている。
      https://without.boats/blog/why-async-rust/
      async/await が Rust に技術的によく適合するかどうかは重要ではなく、JavaScript プログラマが async/await に慣れていたから Rust にも async/await が必要だった、という話に見える。
    • async/await もスレッドと同じく 並行性メカニズム であり、共有メモリにアクセスするなら常にロックが必要だ。ロックが不要だという話がどこから出てきたのか分からない。
    • async はロックの面でより恐ろしいことすらある。
      あるコードブロックが排他的アクセスに依存していて、await がなかったのでそれが保証されていたのに、途中に await を追加するとコードが壊れる。スレッディングなら少なくとも、何に排他的アクセスが必要かをコードで明示させる。
      async は自分でスレッドスケジューリングを管理することを意味する面もある。I/O が多く CPU バウンドなコードが短いなら問題ないが、CPU バウンドなコードがある、あるいはたまにでも現れるなら、スケジューラ遊びをすることになる。
    • Node.js を使うチームに加わったとき、バックプレッシャー の問題を経験した。
      いくつものサービスがただ ABEND して落ちていき、Java から来た身としては、こんな抜けがあることに驚いた。チームに修正方法を説明するのも難しかった。
      エラー伝播 のせいで、選択肢があるなら async/await は使いたくない。個人プロジェクトなら maybe だが、他人と一緒に作業し、ライブラリを使い、全員の理解をそろえなければならないなら絶対に嫌だ。
      言語レベルの構造化並行性はまだ本格的には使っていないが、Java の Loom Project には期待している。見たところ、この論争自体を無意味にしてくれそうだ。
    • Rust では async/await が 1 スレッドの文脈でしか実行されないわけではない。
  • この記事には問題がある
    例が Web サーバー 1 つだけで、スレッド側の解法も誤っている。また、あたかも人々が async/await の代わりに OS スレッドを望んでいるかのような問いになっている
    プログラマが欲しいのは概念的・意味論的なスレッドだ。逐次的なロジックを書き、async のような奇妙な注釈を書かなくて済むことだ。async/await がそんなに良いのなら、すべての関数を暗黙に async にして、await の代わりに普通の関数呼び出しを使えばいいのではないか。そうなれば実質的にはスレッドでプログラミングしているのと同じだ
    OS スレッドは静的に割り当てられたスタックのため高コストで、私たちが欲しいのは単一 CPU 上で数百万個動かせる安価なスレッドだ。ただし、無骨な async/await という語抜きで。wait はイベントや他スレッドの完了を待つ古典的な意味でのブロッキング待機には残っていてもよいが、関数呼び出しには望まない
    Web サーバーの例に戻ると、driver.race(timeout).await でタイムアウトを実装したとき、race がタイムアウトエラーを通知した後、クライアントソケットはどうなるのか。開いたまま接続状態でリークしないのか
    スレッド版のタイムアウトも threaded_race(client_thread, timeout).wait のように async/await とほとんど同じ形にできる。threaded_race はタイマーでスレッドと並行してタイムアウトを追跡し、時間になれば Java 方式で client_thread.interrupt() を呼ぶ。Thread.interrupt() はスレッドがブロックされていなければフラグを立てるだけで、I/O 呼び出しでブロックされていれば InterruptedException を投げる。これはチェック例外なので、コンパイラが client.read_to_end(&mut data) を try/catch で囲むか、handle_client に例外を宣言するよう強制し、プログラマがクライアントソケットを閉じ忘れないようにする

    • race() の内部の値は Drop され、driver 自体は残る
      そのままの型を使うと Rust は Result を処理していないと文句を言うだろうし、future の中で新しいソケットがローカルに作られていたなら、それはクリーンアップされる
      Rust の future の良い点は、その周辺の動作をすべて定義できることにある。すべての関数がブロッキングであるというモデルとは異なり、Rust ではタスクキューの次の仕事へ実行を先送りする時点を指定でき、明示的に保持された状態(Future 構造体)を使ってタスクを任意に高速でポーリングできる。だからスレッドのように譲るために sleep() する必要がなく、高速で推論しやすい
      Java の Thread.interrupt も結局は sleep ループに近く、多くのアプリケーションではそれで十分かもしれない。しかし Rust はシステム言語なので、組み込みシステムではそのような方式は取れないし、カーネルや低遅延アプリケーションにも望ましくない
    • 逐次的なロジックを望むプログラマもいるが、多くの人はまったく逆を望んでいる
      ほとんどの場合、それが OS のブロッキングシステムコールかノンブロッキングシステムコールかはあまり気にしないが、読んでいるプログラムの制御フローを理解し、どこで待機し、どう並列に実行できるかは知りたい
      むしろブロッキング関数で作業するときには blocking/block キーワードの組があってほしい。ブロッキング呼び出しは気づかないうちに全体を遅くしうるし、UI スレッドにブロッキングシステムコールが入っていて、もっさり遅いアプリをあまりにも多く見てきた
    • すべての関数を暗黙的 async にして普通の関数呼び出しのように使う方式は、ここ数十年で何度も試されてきた。“RPC” を調べればよい
      同期と非同期を統合しようとする試みはどれも失敗してきた。1 つのスレッド内で実行されるコード、スレッド間で実行されるコード、さらにはコンピュータ間で実行されるコードには大きな意味論的差異がある。それを抽象化しようとすると結局は不十分になるので、最初からきちんと学ぶほうがよい
    • withoutboats がどこかの記事で、本当の答えはC との互換性だと言っていたのを覚えている
    • poll()select() でコードを書く方法もあるが、それはまた別のやり方だ
  • async/await の面目を保とうとする、ほとんどマーケティングのようなキャンペーンを見るのは興味深い
    私の経験では、これは技術的な失策だっただけでなく、コミュニティにも大きなコストを払わせた。実際に有用な言語機能に集中する代わりに、Rust の努力はこの混乱のために脇道へそれた
    それでもこの言語には今なお大きな期待を持っており、現時点であるものの中では最高だと思う。ただ、この争いが永遠に続くのではないかと心配している
    追記: AsyncWrite/AsyncRead の例はもっともらしく見えるが、実際には *nix に限定すれば、スレッドとファイルディスクリプタでも同じことができる

    • ファームウェアで async を使ったことがあるが、命の恩人だった
      そうした一般化には根拠が乏しく、特定のワークロードに偏っているように見える
    • Rust にはあまり詳しくないので正しいかは分からないが、体感として最近 HN/reddit で見る Rust 議論の 10 件中 9 件は async をめぐるものに見える
      async にはまったく興味がなく、Rust の話を読みたい立場からするとかなりうんざりだ
    • スレッドが poll() より速いと思うなら、どんなユースケースなのか知りたい。生まれてこのかた一度もそんなケースを見たことがない
    • 技術的な失策ではなく、超低遅延 async コードが必要なときには優れた解決策だ
      失策だったのは、そうしたものが不要な大多数のユースケースにまで押し広げたことだ
    • async の作業がほかの有用な機能を奪ったという主張に根拠があるのか気になる
      多くの主要な Rust プロジェクトは、スレッドベースの代替より大きな性能向上があるからだけでなく、設計上の特性ゆえに async に依存している。主要な I/O バウンドのワークロードでは、こうした利点は容易に見て取れる。現実の問題を解く賢い人々が主要クレートで async を広く採用しているという事実は、async が実際に有用な言語機能であることを示す強いシグナルだと思う
      争いは主に Hacker News や reddit で、async を必要としない人々が使う I/O クレートが今や async を求めているという理由で腹を立てる形で起きている。面白くない状況だというのは理解できるし、async に実際の問題があり、まだ解決途中なのもそのとおりだ。完璧ではない。しかし、フォーラムで見える async の分裂は、実プロジェクトでのそれほど広範でも劇的でもないように感じる
  • 大きく抜けているのは キャンセル
    future は非常に簡単にキャンセルできる。一方でスレッドのキャンセルは、もぐらたたきのように泥臭く、スレッドの強制停止はロックを保持したままになる危険があるため信頼できない
    Rust の async モデルでは、すべての future に外部からタイムアウトを付けられる。末端の I/O 関数ごとにタイムアウトオプションをサポートする必要もなく、そのタイムアウトを呼び出しスタック全体に渡す必要もない
    進行中の状態管理に Rust のベストプラクティスである Drop ガードを併用すれば、大きく複雑な処理でも簡単かつ確実にキャンセルできる

    • すべての future を簡単にキャンセルできるのではなく、すべての future をキャンセルしたふりをしやすいだけである
      たとえば spawn_blocking を使う何かをキャンセル(drop)しても、バックグラウンドでは実行が継続し、ユーザーは気づかないかもしれない。スレッドプールで実装された async のファイルシステム処理も、キャンセルしても実行は続く
      こうしたことは、「もう誰もそのファイルを書いていないはずなのに、なぜサービスが『ファイル使用中』で失敗するのか?」のような、理解しづらいバグにつながりうる
    • async future を実装できたなら、その代わりに キャンセル可能なスレッド も実装できたはずである
      問題はかなり同型的である。システムコールは難しいが、スレッドで行っても async future で行っても、同じシステムコールを使うならまったく同じキャンセルの問題が発生する
    • なぜスレッドのキャンセルが難しいのかわからない
      すべてのスレッドがアクセスできるフラグのような状態を用意し、作業ループの中でそのフラグを確認するようにすればよい。false なら return してスレッドを join すれば終わりである
    • 私の経験では、キャンセルはそれほど気にする価値がなかった
      ある処理がもはや有用でないなら、その情報が最終的に、その処理の代わりに呼び出された関数に見えるようになれば十分である。RPC の開始のような非常に高コストなことを行う直前でなければ、わざわざ確認しない
  • よりよい問いは「なぜ fiber ではなく async/await なのか?」だと思う
    Rust が 1.0 以前にグリーンスレッドを持っていて、意図的にそれを削除したことは知っているが、言語に重いランタイムを組み込まずに済む方式など、fiber ベースの並行性実装にはいくつものアプローチがある
    この記事を正しく理解したなら、future をいつでも drop できる点を主に称賛しているように見える。スレッドでは明白な理由から似たことはできず、技術的に可能だとしても極めて安全ではない。しかしこの能力には非常に大きなコストが伴う。io-uring のような完了ベースの実行系とスタックベースの配列を一緒に使えなかったり、下位タスクを別の実行系スレッドで実行できなかったりするだけでなく、同期 Rust を使っているとかなり不快な驚きになる微妙な落とし穴や信頼性の問題も生じる
    https://smallcultfollowing.com/babysteps/blog/2022/06/13/asy...
    タスクのキャンセルは本質的に 協調的キャンセル であるべきで、非協調的キャンセルは表面的には便利でも、その下に深い問題を抱えた誤った機能に近いと思う
    また async/await の合成可能性を称賛するのも奇妙だ。proper effect system がない現在の Rust では、その伝染性のために合成可能とはほど遠い。たとえば標準ライブラリの map メソッドと async クロージャを一緒に使ってみるか、標準の io::Read/Write トレイトを使ってみればよい

    • Rust に fiber をユーザー空間の協調的並行性抽象として入れるなら、いくつもの設計判断を強いることになる
      スタックをスパゲッティスタックとして実装するか、プロセスレベルのメモリマッピングライブラリを要求するか、あるいは固定サイズのスタックに制限するかといった選択が必要になる
      3 つの方式はいずれも、異なる ABI を持つ言語コードと相互作用するときに問題になる。たとえばある fiber から C コードを呼び出し、その C コードが別の fiber を再開しようとすると、かなり複雑になりうる
      async/await の利点のひとつは await キーワードそのものだ。明示的な待機点があるおかげで、並行プログラムの相互作用を実際に推論できる
      yield する fiber は、並行性の世界における goto に近い。あるメソッドを呼ぶとき、副作用として処理が止まるのか、再開したときに世界の状態が変わっているのかがわからない。外界と接するときには防御的に書く必要があるため、fiber は隔離して実行され、完了によって通信するタスクにより向いている
      グリーンスレッド、fiber、コルーチンはここでは同じ問題を共有している。ユーザー空間の協調的並行性は、並行性の難しい部分を解決するというより、机の上の書類を別の場所へ移すことに近い。Rust async/await はより明示的なので、他のメカニズムが隠してしまう副作用を隠さない
    • fiber がキャンセルの問題をどう解決するのかわからない。ほとんど同等ではないか?
      fiber ベースのコードは進行中のスレッドを頭の中で追跡しなければならず、追いかけにくいと感じる。少なくとも自分にとっては、完了する値を追跡するほうがずっと簡単だ
    • スタック付き fiber は低レベルコードにはあまり向かない
      C++ 委員会向けの Gor Nishanov のレビュー http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p136...を見ればよい。https://devblogs.microsoft.com/oldnewthing/20191011-00/?p=10...からもリンクされている。要約も明快だ: DO NOT USE FIBERS!
    • Ruby なのでスレッド/GVL の状況は Rust とは違うが、こういう種類の話を指しているのかと思う
      https://m.youtube.com/watch?v=qKQcUDEo-ZI
      async/await が伝染性を持ち不自然であり、fiber が少なくとも Ruby 実装でははるかに優れたパラダイムであることを、かなりうまく示していると思う
    • 数学の問題では 協調的キャンセル はかなり厄介になりうる
      最適化アルゴリズムが求根問題を呼び、それが ODE 積分器を呼ぶ、というように入れ子になることがあり、どの段階でも非常に長く走る可能性がある。キャンセルトークンをあちこちに渡す必要があるが、数値計算フレームワークはたいてい対応していない
      すべてのアルゴリズムに反復回数の制限を設けることはできるし、そうすべきだが、入れ子のアルゴリズムでは「今年中には止まる」程度しか保証せず、5 秒以内に止まることを保証するのは難しい
      こうした問題では、自分がしていることが大量の数学計算、割り当てとそれに伴うページフォールト、I/O なし、そしてキャンセルしない Main スレッドで処理する標準ライブラリの Queue オブジェクトへログ文字列を書くだけだと保証できる。必要な他の機能も Queue でメインスレッドに戻せる
      21 世紀には、キャンセルトークンをどこにでも無理やり渡し、トークン確認なしに長時間走るコードを防御的に書かせることなく、この問題を解決できるべきだと感じる
  • またひとつ async/await の議論で、人々は async/await を理解しておらず、なぜ単一スレッドで並行性メカニズムが必要なのか想像できず、誰も必要としていないと決めつけている
    UI プログラミング、GPU との通信、ランタイム間通信がよい例で、ほかにもあるだろう
    スレッドはグリーンスレッドであれ何であれこうしたケースには向かないが、async/await は向いている

    • GUI でスレッドを簡単に使うことはできるし、過去にはスレッドをかなり効果的に活用した GUI アプリをいくつも書いたことがある
    • 単一スレッドで複数のタスクを明示的に管理できることが重要なのは確かだ
      同じバイナリを作りつつ、言語利用者にとってより煩わしくない別の言語機能の実装が可能なら、その話をしてみる価値もあるだろう
  • Rust の async/await の主な利点の 1 つは、スレッドや動的メモリがない環境でも動作できる点だ。
    マイクロコントローラで、割り込みがどこかのバッファに入ってくる I2C データを読み取るまで待つような簡潔なコードを書くのにも十分使える。下位ランタイムとの相互作用をあまり露出させずに並行性を使える、より高水準の抽象化である。
    私が手がけた主要なソフトウェアはすべて、何らかの形でこれを実装していた。モダンな C++ コルーチンの概念がないコードでも、Apple Grand Central Dispatch や Intel Threading Building Blocks などを使っていた。そうでなければ、ビジネスロジックが I/O で非常に非効率にブロックされるか、スレッドが膨大になって開発とデバッグが地獄になるか、下位ランタイムの実装詳細まみれになるか、あるいはその 3 つが混ざる。
    言語自体やライブラリの既存の抽象化を使わなければ、結局は自分で作ることになり、それは難しく、広く使われているものより全体として劣る可能性が高い。過去に C++ 向けに自作したこともある: https://github.com/goto-opensource/asyncly

  • 著者は 2 つのものを混同しているように見える。
    1 つは ユーザー空間スレッド/グリーンスレッド で、もう 1 つは 構造化並行性 だ。
    前者は async/await の利点ではあるが、固有の利点ではない。Go や Java Loom のように、関数の色問題なしで実現できる例がある。
    後者は OS スレッドでもグリーンスレッドでも実装できる。Java の Structured Concurrency JEP を見ればよい。
    https://openjdk.org/jeps/462