スレッドではなく async/await を選ぶ理由
(notgull.net)- Rust の
async/awaitはスレッドの単純な代替ではなく、I/O 中心の並行コードを 合成可能な状態機械 として表現するプログラミングモデルである - Web サーバーのように複数の接続を同時に扱うコードは、線形実行だけではすぐに限界が生じるが、スレッドは
thread::spawnによってクライアント処理を分離し、同時処理 を可能にする async/awaitはawaitの地点で実行を譲り、エグゼキュータがほかのタスクを続けて実行できるようにすることで、多数のタスクを 1 つのランタイム内で交互に実行する- 3 秒のタイムアウトのような要件は、
asyncではraceとTimerの組み合わせで付加できるが、同期スレッドコードでは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_allをawaitする非同期関数なら、3 秒タイムアウトは 2 つの Future を組み合わせて実装できる- この方法は
TcpStreamだけに縛られないimpl AsyncRead + AsyncWriteを実装する対象なら同じパターンを適用できる- 一般的なストリーム上の GZIP ストリーム、Unix ソケット、ファイルのような対象にも置き換え可能である
同期スレッドコードで同じタイムアウトを実装する際の制約
- ブロッキングコードでは一般に
readやwriteのシステムコールを中断しにくく、ファイルディスクリプタを閉じるといった方法は Rust では使えない TcpStreamはset_read_timeoutとset_write_timeoutを提供する- 読み取りと書き込みそれぞれにタイムアウトを設定できる
- ただしクライアントが 2.9 秒ごとに 1 バイトずつ送ると、単純なタイムアウトは継続的にリセットされ得る
- これを防ぐには、
TcpStreamを包むDeadlineStreamのような型を作り、全体のデッドラインまでの残り時間を毎回計算して、読み書きタイムアウトに設定しなければならない - このアプローチは機能し得るが、制約が大きい
TcpStreamに縛られる- Rust には
set_read_timeoutとset_write_timeoutの利用を抽象化する trait がない - 汎用 writer に適用するには追加作業が多く必要になる
- タイムアウト設定のための追加システムコールが発生する
- 実際の Web サーバーロジックでは利用がさらに煩雑になり得る
Rust async エコシステムの事例
- HTTP エコシステムがクライアント側まで含めて
async/awaitを主要なランタイム機構として採用した背景には、関数の合成可能性がある- HTTP 呼び出しを作る関数を、さまざまな隙間やユースケースに合わせて差し込める
towerはasync/awaitの合成可能性を示す代表例である- サービスを
async関数として実装すれば、タイムアウト、レート制限、ロードバランシング、hedging、バックプレッシャー処理を追加できる - どのランタイムを使うか、サービス内部で何をしているかに関係なく
towerを適用して堅牢性を高められる
- サービスを
macroquadは Rust 向けの小型ゲームエンジンで、メイン関数にasync/awaitを使ってエンジンを実行する- Rust で何らかの処理を待つために線形関数を止めなければならない状況を表現するのに
async/awaitは適している - 同じスレッド上でゲームサーバーのネットワーク接続と GUI フレームワークを同時にポーリングするといった構成が可能になる
- Rust で何らかの処理を待つために線形関数を止めなければならない状況を表現するのに
性能だけで async を説明する際の限界
- Rust Async Book は、OS スレッドはプログラミングモデルを変えずに並行性を表現しやすい一方、スレッド間同期が難しく、性能オーバーヘッドが大きく、スレッドプールでも大規模な I/O bound ワークロードを十分に支えにくいと比較している
asyncコミュニティでは、なぜ OS スレッドではなくasyncを使うのかと問われたときに、「オーバーヘッドが低く、あとは同じだ」といった形で答える傾向がある- Web サーバー作者たちが
async/awaitへ移行した理由は C10k problem を解決するためだったが、すべての利用者にとってasync/awaitを選ぶ理由が性能である必要はない - 性能上の利点は状況によって消えることがある
- CPU bound な処理では、同等の
asyncワークフローよりスレッドベースのワークフローのほうが速い場合がある - Rust
asyncの一時的な性能優位は過度に強調され、意味論的な利点は過小評価されてきた
- CPU bound な処理では、同等の
async/awaitはニッチなケース向けの道具ではなく、同期 Rust では数十本のスレッドとチャネルなしには簡潔に表現しにくいパターンを扱う強力なプログラミングモデルである
sync Rust のようにするのではなく違いを受け入れる
- Rust プロジェクトのロードマップには、
async Rustの記述は、ときどきasyncとawaitキーワードを使うことを除けば、同期コードを書くのと同じくらい簡単であるべきだという方向性がある - しかし
async Rustを「sync Rust とまったく同じ」にするというフレーミング自体が本質的に難しいという見方もある- 99% まで似せられても、平均的な利用者は違いに気づかざるを得ない
- Rust の
async/awaitエコシステムは同期 Rust と同じになろうとするより、合成可能性 と 表現力 という強みをより明確に打ち出すべきである - 並行性が必要なときに
async/awaitが基本の選択肢になるようにするには、技術的な性能上の理由より、意味論的な理由でこのモデルを説明すべきである
1件のコメント
Hacker Newsの意見
シングルスレッド async/await は単純でよく知られたモデルであり、JavaScript がその方式。
スレッドは複数の CPU を問題解決に投入でき、Rust はロック管理を助けてくれる。優先度の異なるスレッドも持てるため、計算バウンドな処理では必要になることがある。
一方で マルチスレッド async/await は厄介になる。計算バウンドな区間が本格的に入ってくると、他のタスクと共有しているスレッドを事実上塞いでしまうため、モデルが崩れやすい。
Rust の計算バウンドなマルチスレッドは、期待するほどうまく動かない。アロケータで複数スレッドが同じロックを叩いて futex 輻輳崩壊 が起きることがあり、とくにバッファ拡張中にアロケータ全体をロックしたまま再コピーが発生すると非常に高コストになる。Wine の Microsoft ライブラリエミュレーション .DLL 内のライブラリアロケータはこの問題に弱く、CPU 時間がスピンロックにほぼ吸われて性能が二桁倍悪化したが、Microsoft 実装ではそうではなかった。
また標準の
Mutexとcrossbeam-channelチャネルでは 不公平な mutex の飢餓 が起きることがある。複数スレッドが資源をロックして処理し、解放してを繰り返すと、1 本のスレッドが勝ち続けて残りが締め出されることがある。公平な mutex が必要ならparking-lotがあるが、標準 mutex が提供する、スレッドパニック時の poisoning による安全性はない。I/O バウンドでないなら、はるかに複雑になる。
https://users.rust-lang.org/t/mutex-starvation/89080
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
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 化することを要求しなかったなら、不満の大半は消えていたように思う。
さらに悪いのは、エコシステムが二つに分断されるだけでなく、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 を返してくれる。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 実装はネットワークのバックプレッシャーが不足していることで悪名高く、それに起因する問題がある。
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 が必要だった、という話に見える。
あるコードブロックが排他的アクセスに依存していて、
awaitがなかったのでそれが保証されていたのに、途中にawaitを追加するとコードが壊れる。スレッディングなら少なくとも、何に排他的アクセスが必要かをコードで明示させる。async は自分でスレッドスケジューリングを管理することを意味する面もある。I/O が多く CPU バウンドなコードが短いなら問題ないが、CPU バウンドなコードがある、あるいはたまにでも現れるなら、スケジューラ遊びをすることになる。
いくつものサービスがただ ABEND して落ちていき、Java から来た身としては、こんな抜けがあることに驚いた。チームに修正方法を説明するのも難しかった。
エラー伝播 のせいで、選択肢があるなら async/await は使いたくない。個人プロジェクトなら maybe だが、他人と一緒に作業し、ライブラリを使い、全員の理解をそろえなければならないなら絶対に嫌だ。
言語レベルの構造化並行性はまだ本格的には使っていないが、Java の Loom Project には期待している。見たところ、この論争自体を無意味にしてくれそうだ。
この記事には問題がある
例が 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 スレッドにブロッキングシステムコールが入っていて、もっさり遅いアプリをあまりにも多く見てきた同期と非同期を統合しようとする試みはどれも失敗してきた。1 つのスレッド内で実行されるコード、スレッド間で実行されるコード、さらにはコンピュータ間で実行されるコードには大きな意味論的差異がある。それを抽象化しようとすると結局は不十分になるので、最初からきちんと学ぶほうがよい
poll()やselect()でコードを書く方法もあるが、それはまた別のやり方だasync/await の面目を保とうとする、ほとんどマーケティングのようなキャンペーンを見るのは興味深い
私の経験では、これは技術的な失策だっただけでなく、コミュニティにも大きなコストを払わせた。実際に有用な言語機能に集中する代わりに、Rust の努力はこの混乱のために脇道へそれた
それでもこの言語には今なお大きな期待を持っており、現時点であるものの中では最高だと思う。ただ、この争いが永遠に続くのではないかと心配している
追記:
AsyncWrite/AsyncReadの例はもっともらしく見えるが、実際には *nix に限定すれば、スレッドとファイルディスクリプタでも同じことができるそうした一般化には根拠が乏しく、特定のワークロードに偏っているように見える
async にはまったく興味がなく、Rust の話を読みたい立場からするとかなりうんざりだ
poll()より速いと思うなら、どんなユースケースなのか知りたい。生まれてこのかた一度もそんなケースを見たことがない失策だったのは、そうしたものが不要な大多数のユースケースにまで押し広げたことだ
多くの主要な Rust プロジェクトは、スレッドベースの代替より大きな性能向上があるからだけでなく、設計上の特性ゆえに async に依存している。主要な I/O バウンドのワークロードでは、こうした利点は容易に見て取れる。現実の問題を解く賢い人々が主要クレートで async を広く採用しているという事実は、async が実際に有用な言語機能であることを示す強いシグナルだと思う
争いは主に Hacker News や reddit で、async を必要としない人々が使う I/O クレートが今や async を求めているという理由で腹を立てる形で起きている。面白くない状況だというのは理解できるし、async に実際の問題があり、まだ解決途中なのもそのとおりだ。完璧ではない。しかし、フォーラムで見える async の分裂は、実プロジェクトでのそれほど広範でも劇的でもないように感じる
大きく抜けているのは キャンセル
future は非常に簡単にキャンセルできる。一方でスレッドのキャンセルは、もぐらたたきのように泥臭く、スレッドの強制停止はロックを保持したままになる危険があるため信頼できない
Rust の async モデルでは、すべての future に外部からタイムアウトを付けられる。末端の I/O 関数ごとにタイムアウトオプションをサポートする必要もなく、そのタイムアウトを呼び出しスタック全体に渡す必要もない
進行中の状態管理に Rust のベストプラクティスである
Dropガードを併用すれば、大きく複雑な処理でも簡単かつ確実にキャンセルできるたとえば
spawn_blockingを使う何かをキャンセル(drop)しても、バックグラウンドでは実行が継続し、ユーザーは気づかないかもしれない。スレッドプールで実装された async のファイルシステム処理も、キャンセルしても実行は続くこうしたことは、「もう誰もそのファイルを書いていないはずなのに、なぜサービスが『ファイル使用中』で失敗するのか?」のような、理解しづらいバグにつながりうる
問題はかなり同型的である。システムコールは難しいが、スレッドで行っても 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トレイトを使ってみればよいスタックをスパゲッティスタックとして実装するか、プロセスレベルのメモリマッピングライブラリを要求するか、あるいは固定サイズのスタックに制限するかといった選択が必要になる
3 つの方式はいずれも、異なる ABI を持つ言語コードと相互作用するときに問題になる。たとえばある fiber から C コードを呼び出し、その C コードが別の fiber を再開しようとすると、かなり複雑になりうる
async/await の利点のひとつは
awaitキーワードそのものだ。明示的な待機点があるおかげで、並行プログラムの相互作用を実際に推論できるyield する fiber は、並行性の世界における
gotoに近い。あるメソッドを呼ぶとき、副作用として処理が止まるのか、再開したときに世界の状態が変わっているのかがわからない。外界と接するときには防御的に書く必要があるため、fiber は隔離して実行され、完了によって通信するタスクにより向いているグリーンスレッド、fiber、コルーチンはここでは同じ問題を共有している。ユーザー空間の協調的並行性は、並行性の難しい部分を解決するというより、机の上の書類を別の場所へ移すことに近い。Rust async/await はより明示的なので、他のメカニズムが隠してしまう副作用を隠さない
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!
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 は向いている
同じバイナリを作りつつ、言語利用者にとってより煩わしくない別の言語機能の実装が可能なら、その話をしてみる価値もあるだろう
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