async/awaitは今後もベストフレンドであり続けるか
えーあーー
いくつかみかけた、async/await と java loom/goroutine的なものを比較する議論に興味があり、少し追ってみた感想です。実装まで深く調べられてません。
TL;DR
JVM (Java) は対象的に、コードの書き方をなにも変えなくてもランタイムが自動的にI/Oを非同期にしてくれる夢のVirtual Thread を導入した。
async/awaitが常に中断ポイントを明示することは欠点でもあるかもしれないが、どうせ明示的な中断/再開のようなものをプログラムする道具はなにか必要ではないだろうか。
async/await のコンパイラによるサポートを手書きする勢力がC界隈にあるらしい。
C# といえば async/await 。サーバサイドC#が高スループットであったりリアルタイム性の高いサーバに採用できる大きな理由のひとつにasync/awaitがある。あと ゲームを書くときにasync/awaitがあると便利。
近年はJavascriptを筆頭にRust、Swift、Zig、ついでにPython でもasync/awaitが同じ名称で導入されていて、非同期処理の道具としてはある一定の地位を得ている感がある。
(Kotlinのコルーチンも async/await キーワードがあるので似ている。こっちは基本的には中断ポイントに await と書かせないことに腐心しているので、そういう意味ではgoroutineに少し近いのかもしれない)
(Swiftのasync/await は 再デザインされていて C#との違いがあっておもしろい。structured concurrency という標語があって、未完了のTaskのリークをコンパイル時に防ぐデザインになっているようだ)
これは今までと全く同じように同期的プログラミングをしているだけでJVMランタイムとライブラリが VIrtual Thread上で走るコードを勝手に非同期にしてくれるというもの。
「いいんだ。君はasync/awaitについて何も知らなくても」JVMより
async/await ってむずかしいのかな
僕はasync/await のような、中断/再開が明示されているスタイルは良いとおもうしけっこう気に入っているのだが、初見では「わかりずらい」「むずかしい」と言われがちらしく、嫌うプログラマも一定数いるらしい。
僕の考えでは、async/await で書かれたものがどの順番に実行されるかは見た目のとおりなのでそんなに難しくなくて、どちらかといえば内部でどのように動作しているのか想像がつかないことに難があるんだろうと思う。
昔からUnity界隈 では本職の武闘派でなくても コルーチン(yieldするやつ)便利だねって使っていたのを私は知っている。しかしasync/awaitを元にコンパイラがステートマシンを生成することや、それがいつどのようにして遷移するか、といったことはソースコードからは不透明で、調べないとけっこう想像しずらい。
さいきん 自分は UniTask のコミッターをしていたのだけど、コンパイル結果がどうなるか頭の中で全部イメージするのはけっこう慣れないと厳しいとおもった。 async/await の良いところのひとつはエラーをちゃんと伝搬できることだけど、非同期であることを踏まえると、逆にどうして await を跨いで try/catch がちゃんと動作するのか直感的には不思議ではある。
( Haskellとかのdoとか、Rxとか、モナド的なもので副作用を包んでましてそれを合成していきます、みたいなやつのほうが、使いやすさは色々だけど中身の動きはもうすこし想像しやすいのかもしれない。
だからもし我々のようによくIDカードを忘れて会社から閉め出されているような人間がこの「むずかしいやつ」を扱わなくてよくなり、JavaのVirtual Theadのようにランタイムにすべてを委ねられるなら、もしかしてその方が良いんじゃないだろうか……?
ふとそういう疑問も湧いてくる。
C# や Javascript なんかは、非同期処理を書くための道具が コールバック地獄 → 未完了の処理を値で包む( Task / Promise / Future / Rx) → async/await の順に進化していった。遡るほど、過去には非同期処理を組み合わせる難易度が異常に高かった。その代わりどのように動くかは想像しやすかった。
そういう意味ではこれらは結局複雑さをどこに持っていくかという違いで、時代を経るとにプログラマの見えないところに複雑さが移動していっているという見方もできるのかもしれない。結局ちゃんと使うならそれなりに理解していないといけないと思うけど。
async/awaitの用途
Java の VIrtual Thread が想定している目標はJavaで書いたサーバのスループットを上げることのようだ。
非同期処理の中には「理想的には即完了してくれればべつに同期的に実行すりゃいーんだけど、スループットがでないもんで、しかたなく非同期にしてえんだわ」という類のものがある。C# その他、I/Oでスレッドをブロックしない高スループットなランタイムは世の中にいくつもあれど、そういった機構を持たない言語のサーバのフレームワークでは、「知らんがな」という感じでI/Oだろうとなんだろうとぜんぶ同期的プログラングで書いてるのがその証拠である。
( これはたぶん百回くらい聞いた話の百一回目になってしまうけど、サーバにおいてはI/Oでネイティブスレッドをブロックするかしないかでスループットがけっこう違う。コネクションをつなぎっぱなしにしてクライアントと双方向通信したい、みたいなことになってくるとここはさらに重大になる。
スレッドがブロックされると、カーネルがそれを検知して別のスレッドにCPUを使わせないといけない。このコンテキストスイッチはコストがある。CPUキャッシュにもよくないかもかもしれない。
そのアプリケーションのスレッドがすべて待ち状態になっていたら、仮にリソースに空きがあってもなにもできない。
かといって、スレッドは1本で1MBほどの領域を確保しているので、生成しまくるとすぐにリソースが枯渇。
この、プログラマから見たらロジックとしてべつに同期でいい(ただしいっぱい並行実行できるなら)、という部分に関しては、Java のVirtual Thread がはまりそうである。既存のjava資産をこれに置き換えていくのは時間がかかりそうな気はするけど、goroutineのような成功例も世の中にはあるからポテンシャルはありそうだ。
しかし冷静に考えると、async/await やRx やコールバックとかは、一旦中断してからある期待するタイミングで再開したい、というものにも使われる。
画面になんか出すクライアントサイドのアプリケーションを考えてみる。ほとんどすべての環境では画面を更新する速度に合わせて「メインループ」が「メインスレッド」上にスケジュールされている。
このメインスレッド上で、毎フレームなめらかに「200ミリ秒かけて右に100ピクセル動く」とか、あるいは単純に「次のフレームで継続したい」みたいなことを書きたいとき、これもプログラマから見たら非同期処理として扱える。
code:cs
while (t <= 1f)
{
position.x += (elapsed / 200f) * 100;
await NextFrameAsync(); // ここで中断
// 次のフレームになったらここから再開
t += Time.deltaTime;
}
もちろん、メインスレッドをブロックしてしまうとユーザ体験が悪化するからなるべく非同期にしたい、というモチベーションはまったく共通で引き継いでいるのだが、それ以前に、アニメーションのような「いくつかのフレームを跨いで完結する処理」を書きたい場合には、ブロックしてしまうと書くことができない。
もしメインスレッドがJava の Virtual Thread のようなものだったとしても、メインループは回し続けないと目的が果たせない。だからこれをVIrtual Thread で実装するには、ランタイムに 次のフレームで再開 命令をあらかじめ理解してもらうか、あるいは継続したいものを別のVirtual Thread として起動することになるんじゃないだろうか。しかしそれだけだとけっきょくコールバック地獄時代と大して変わらなくなってしまう。
僕の理解では、Goがチャンネルに特別な文法を与えている理由は、けっきょくユーザが明示的にgoroutineを中断/再開させるケースが確実にあるからだろう。
てことで、async/await じゃなかったとしても「コードの色づけ」問題はどこかに残るってことだとおもう。もちろん async/await はあまりにも色がつきすぎかもしれませんが。
Green Thread Experiment Results
といったことを思っていたある日、こういうissueがあるのを教えてもらった。
.NET の中の人が、JavaのVirtual Threadのようなものを実験的に実装してみたらしい。
ちなみにここでは、以下のようなasync/awaitの代替としてのスレッドを模したものをグリーンスレッドって呼んでいる。
1. プログラマが中断ポイントを明示せず、スケジdューラが自動で中断/再開を判断する (プリエンティブ)
→ 対してasync/await は、プログラマが中断ポイントを明示するので Cooperative=協調的 というような分類になるようである。
2. 本物のスレッドのようなスタック領域のスイッチ (スタックフルコルーチン)。
プリエンティブなスレッドを自前実装するにはこれが必要になるってことだとおもう。
→ 対して async/awaitは、再開のために必要な変数のみをキャプチャする。
( async/await のようなものは「非同期に完了する処理」一発一発をTaskだのFutureだのPromiseだのとかでモデリングしている。対して goroutine や java loom のようなものは「スレッド」という概念そのものが特別版になってる。
この2つは使い勝手につながっていて、できたものを見比べると、「君のスレッド」に魔法がかかっているパターンでは、これを使うあなたはそれが特別であることをあまり意識しない。「Task」が特別なパターンでは、君のTaskがコンパイラの魔法によって生成されたものかもしれないが、それを使うプログラマは依然として「Task」を「Task」として扱う方法を知っていなければいけない)
このissueの結論としては現在の.NETはもはや async/await に注力する道をいくのが良いということになったようだ。
以下が理由として挙げられている。
既存のasyncとグリーンスレッドを混ぜることが複雑。
ネイティブコードとの相互運用は複雑で、比較的遅い。ベンチマークではP/Invoke が6倍悪化した。
同様の問題はグリーンスレッドを実装している他の言語にも影響を与えている
このissueを見て初めて知ったが、一般にプリエンティブなグリーンスレッドはスタック管理のコストが乗っかってきてしまうらしい。
コメントを見るとこれについての言及がいくつかある。
@rogeralsing If I understand correctly, the main cost of increased foreign function call overhead comes from stack switching. Green thread is initialized with a small stack and grow by-demand, to reduce memory overhead of having many green threads. The called native code does not have stack growing functionality, so not switching stack could cause stack overflow.
Golang does stack switching when calling FFI which is also slow.
私が正しく理解しているのであれば、外部関数呼び出しのオーバーヘッド増加の主なコストは、スタック・スイッチングによるものです。グリーンスレッドは小さなスタックで初期化され、多くのグリーンスレッドを持つことによるメモリオーバーヘッドを減らすために、必要に応じて成長する。呼び出されるネイティブ・コードにはスタックを増やす機能がないので、スタックを切り替えないとスタック・オーバーフローを引き起こす可能性がある。
GolangはFFIを呼び出すときにスタック切り替えを行いますが、これも遅いです。
IMO, Loom only really works because the JVM has a nearly hostile approach to interop. They could take the gamble that nearly all I/O operations will go through the JVM's own APIs, and thus most projects will be able to take advantage of it. Any project that does use native libraries to perform I/O need to rewrite those libraries to correctly wire up that notification and park the thread. I believe that since the .NET ecosystem is so much more mixed that it would be very difficult to address the amount of blocking non-.NET code which will continue to block the underlying kernel thread, again defeating the purpose.
これらがどのくらい性能の違いになるのか気になる。
async/await もステートマシンをメモリ上に保持したり、継続後に使用する変数をキャプチャしないといけない点では同じだと思うが、asyncにしないで同期的に書くかどうかはいつでも選択できる。また、ネイティブコードにとっては同期か非同期か関係ない。ついでにいえば、Taskは同じルーチンはキャッシュできたりValueTaskを使っていれば同期的fast pass をつくれたりするみたいな最適化ができることも有利なのではと予想。
Why asnc rust
ここでは async/await とグリーンスレッドとの比較がすごくよく整理されて議論されている。
特に、Go の実装について割と引き合いに出されているのが興味深い。
Goと同じようなグリーンスレッドをRustは持っていたが1.0.0 になる前に削除された。その理由も詳しく紹介されている。
Go uses stack copying, and benefits from the fact that in Go pointers into a stack can only exist in the same stack, so it just needs to scan that stack to rewrite pointers. Even this requires runtime type information which Rust doesn’t keep, but Rust also allows pointers into a stack that aren’t stored inside that stack - they could be somewhere in the heap, or in the stack of another thread. The problem of tracking these pointers is ultimately the same as the problem of garbage collection, except that instead of freeing memory it is moving it. Rust could not adopt this approach because Rust does not have a garbage collector, so in the end it could not adopt stack copying. Instead, Rust solved the problem of segmented stacks by making its green threads large, just like OS threads. But this eliminated one of the key advantages of green threads.
自動翻訳) Goはスタック・コピーを使用し、Goではスタックへのポインタは同じスタックにしか存在できないため、スタックをスキャンするだけでポインタを書き換えることができるという利点がある。しかしRustは、スタック内に格納されていないスタックへのポインタも許可しています(ヒープや他のスレッドのスタックに格納されている可能性もあります)。これらのポインタを追跡する問題は、メモリを解放する代わりに移動させるという点を除けば、結局ガベージコレクションの問題と同じです。Rustにはガベージ・コレクタがないため、結局スタック・コピーは採用できなかった。その代わりにRustは、OSのスレッドと同じようにグリーンスレッドを大きくすることで、セグメント化されたスタックの問題を解決した。しかし、これによってグリーンスレッドの重要な利点の1つがなくなってしまった。
Goに明るくないので、単にここに書いてあることの紹介になってしまうが、Goのgoroutineは「軽量スレッド」を表現するために、ネイティブスレッドよりも小さなスタック領域をgoroutineごとに割り当てる。(グリーンスレッドの実装はそのようになる傾向があるらしい) しかしもちろんその小さな領域が足りなくなることはあり、ここで紹介されているGoのアプローチは連続したメモリ領域を確保しなおしてつくりなおすこと。(=stack copying)
Even in a situation like Go, which can have resizing stacks, green threads carry certain unavoidable costs when trying to integrate with libraries written in other languages. The C ABI, with its OS stack, is the shared minimum of every language. Switching code from executing on a green thread to running on the OS thread stack can be prohibitively expensive for FFI. Go just accepts this FFI cost; C# recently aborted an experiment with green threads for this reason.
This was especially problematic for Rust, because Rust is designed to support use cases like embedding a Rust library into a binary written in another language, and to run on embedded systems that don’t have the clock cycles or memory to operate a virtual threading runtime.
自動翻訳) スタックのサイズを変更できるGoのような状況であっても、他の言語で書かれたライブラリと統合しようとする場合、グリーンスレッドはある種の避けられないコストを伴う。OSスタックを持つCのABIは、どの言語でも共有される最小限のものだ。グリーン・スレッドでの実行からOSスレッドスタックでの実行にコードを切り替えることは、FFIにとって法外なコストがかかる可能性がある。GoはこのFFIのコストを受け入れるだけだ。C#は最近、この理由でグリーンスレッドの実験を中止した。
Rustは、他の言語で書かれたバイナリにRustライブラリを埋め込むようなユースケースをサポートし、仮想スレッドランタイムを動作させるクロックサイクルやメモリを持たない組み込みシステムで動作するように設計されているため、これはRustにとって特に問題となった。
先に紹介した C# のissueと同じように、FFIのオーバヘッドについて言及されている。
ネイティブコードに処理を移譲してしまうと、ネイティブコード側には小さなスタックを伸長する機能はないので、そこのケアが必要になるようだ。(自分の理解ではおそらくここでもコピーが発生するのだと思うが、詳しく調べられていない)
たしかに Rust は小さい組み込み用途も想定しているだろうからこの辺は C# よりもシビアなんだろうな。
グリーンスレッドを採用しなかった理由についてはこの辺りの前半で議論されている。
後半は、Rust特有の参照管理の強い制約のせいで、非同期処理のためにクロージャをつくろうとするとなんか使いずらい(Arcだらけ)という問題があり、ここまでの前提を元にその問題への対処として async/await が持ち込まれた説明がされている。
C# の async は、継続する処理を クロージャとして保持できる(している)けど、Rustの場合はその辺の取り回しかたが少し違っていて、Futureを外から poll() するというデザインになっている。この辺を読むとそのデザインになっている理由がわかる。
また、記事の最後のほうでは、Cなどでは、async/awaitでコンパイラがするような非同期のつなぎ合わせを手で書く勢力がいることに触れられており、若干驚いた。
TODO: