ディスカッション (11件)
非同期処理(async)という概念が、かつて私たちに約束した未来と、実際に実装された機能の間にはどのようなギャップがあるのでしょうか。技術的な期待値と現実の使い勝手について深掘りします。
自分はasyncとawaitが好きだな。
非同期プログラミングを学びたくない開発者がいるのも理解はできる。直感的じゃないし、習得するのも難しいからね。
とはいえ「さっさと非同期処理を学べよ、最高だしものすごく価値があるから」とも言いたくなる。
関数の色分け(function colouring)、デッドロック、例外の隠蔽といった問題は、高レベルの抽象化が持ち込んだものじゃなくて、昔ながらの手法にも元からあったものだよ。
AsyncはJavascriptのハックであり、本来必要なかった他の言語に訳の分からないまま移植されてしまったものだよ。
この問題が起きたのはJavascriptにスレッドがなくて、DOMイベントの処理が本質的にイベント駆動だったからだ。公平に見れば、スレッドが持ち込む並行性の問題に対処できる人は稀だけど、スレッドが提供する個別のスタックは絶大なメリットがある。おかげでイベント駆動のコードを逐次処理のコードに書き換えられるんだ。
(コード例省略)
これがこうなる。
(コード例省略)
後者の方が読みやすいし、関心事を一箇所にまとめられる。前者はすぐに関数だらけの巨大なスパゲッティコードになっちゃうからね。
記事で語られてるJavascriptの歴史は、イベント駆動という地獄を、普通のプログラムのような逐次的なコードに置き換えようとする試みの連続に過ぎない。そのプロセスのどこかで、言語側にグリーンスレッドを導入していれば、それで済んでいた話なんだ。新しい構文も、関数の色分けも必要なかったはず。でも、初期のハックを改善し続けて、問題を解決するための「別スタック」という新しいコンセプトの導入という決断を避けた結果が、今のasync/awaitなんだよ。ちなみにasync/awaitも一種の別スタックを作っているようなものだけど、それは非常に効率的なスタック構造ではなく、ヒープ上にmallocされたオブジェクトの連鎖として実装されているからね。
Javascriptコミュニティがその罠に落ちたのは「茹でガエル」の状況として理解できる。でもPythonはどうだ?Pythonには元からスレッドがあったし、GoやErlangがasync/awaitと比べてどれだけうまく機能しているかという手本もあったはずだ。Rustに至っては不可解としか言いようがない。Rustは初期にグリーンスレッドを持っていたのに、それを捨ててasync/awaitを選んだんだ。元の実装に改善が必要だったのは認めるよ。全ての低レベルな呼び出しでイベント駆動とブロッキングのどちらかを選択させるのは間違いだった。今のRustにはその間違いを正したグリーンスレッドの実装が存在するし、それが実装困難ではなかったことの証明でもある。それなのに当時はやらなかったんだ。
プラグイン可能なI/Oインターフェースを持つZigなんかは、ようやく正解にたどり着いたように見えるね。コンパイル時に依存関係としてI/Oを注入する形にしたんだ。「色付き」のasyncキーワードなんてないし、コンパイラが適切なコードを単相化してくれる。I/Oを使うライブラリは一度書けばいい。なんて斬新な概念だろう!Rustでそうならなかったのが残念でならないよ。
並行して処理すべきタスクごとにスレッドを立てるだけじゃダメなシステムってどれくらいあるんだろう?そういうシステムというのは、A)CPUやメモリの制約が厳しい(asyncはディスクやネットワークのIOを速くするわけじゃないから)、B)数万個のタスクを同時に処理しなきゃいけないからタスクをキューに溜めて少数を並行処理するだけじゃダメ、という条件が必須になるはずだ。まともな例を挙げるとすれば、ロードバランサーや組み込みソフト、あとはブラウザくらいかな。でも、REST APIを実装するアプリケーションサーバーがリクエストごとにデータベースとやり取りしなきゃいけないようなケースは、これには当てはまらないと思う。データベース接続やDB側で行われる処理の方が、スレッドのオーバーヘッドより遥かにリソースを食うはずだからね。
async/awaitの議論はいつも非同期のユースケースにばかり注目が集まるけど、同期コードを書くときこそ最大のメリットがあると思ってる。JSでは、文の前にawaitがないということは、計算の途中で他の処理が割り込んでこないということだ。これによって、レースコンディションを気にせずに共有状態へアクセスできるようになる。
もう一つの利点は、型システムにおける大まかな分類ができることだ。「async」とマークされていない関数は、妥当な時間で終了し、例えばUIのメインスレッドで実行しても安全だと作者が考えていることを意味する。その意味では、呼び出し階層全体への伝播はバグじゃなくて機能なんだよ。
関数の複数のバージョンを維持するのがライブラリ作者にとって面倒なのは分かる。でも一方で、fs.readSyncみたいな関数自体があるべきじゃないとも思う。別のコードがスレッド上で動いている可能性がある以上、勝手にスレッドをフリーズさせるのは許容できないからね。
同感だな。OS上でasync Rustを使ってもそれほど感動的ではないし、それならタスクをしっかり定義して手動でスレッドを生成する方がずっと楽だ。
でも、組み込みRustでのasync関数は最高だ!rticやembassyのようなスケジューラと組み合わせれば、ハードウェア抽象化が完全に片付く。シリアルポートなら、たった2層の抽象化でUARTからデータをDMAで爆速転送できる。ターミナルスレッドも、バイト生成して吐き出すために必要な時間しか消費しない。スピンロックも、ステータスレジスタの準備完了待ちも必要ないんだ。
OSスレッドは高コスト:OSスレッドは通常1MBのスタック領域を確保する
1MBのスタック領域を確保することがなぜ「高コスト」なのか?
作成に約1ミリ秒かかる
この数字がどこから来たのかわからないが、数桁ほどずれている気がする。Linuxなら、スレッド作成は10マイクロ秒に近いぞ。
他のエコシステムでasync/awaitを経験した言語設計者たちは、関数カラーリングのコストがメリットを上回ると結論づけ、別の道を選んだ。
それは違うな。著者はGoを証拠として挙げているが、GoのCSPベースのアプローチはasync/awaitが流行るよりずっと昔からあるものだ。それにZigのアプローチにも関数カラーリングは存在する。単に「I/O関数」か「非I/O関数」という色の違いがあるだけだ。これが問題か?多くの文脈、特に低レベルな制御をユーザーに提供したい言語において、関数カラーリングは全く問題ない。関数カラーリングをまるで忌むべきもののように騒ぎ立てるのを見ると、正気じゃないのかと思ってしまう。結局のところ、エフェクトシステムを語るための不適切な表現にすぎないし、エフェクトシステム自体は極めて有用だ。もちろん、Goのような侵入型ランタイムを持つ高レベルな管理言語を望むなら、実行コストと引き換えにその違いを動的に隠蔽する抽象化を作ることもできる(これは動的言語やスクリプト言語など、高レベル言語にとっては統一的な正解かもしれない。ただ、Goの並行処理へのアプローチには不満も多いから、みんなもっと構造化並行処理について学んでくれと切に願うよ)。
コールバック地獄、初期のPromises、そしてasync/awaitへの変遷を経験してきたが、どのステップも改善だとしか思わなかった。実際に使っている分にはデメリットは非常に軽微だよ。
関数カラーリングは興味深いトピックだが、この記事が騒ぎ立てるような理由ではないな。再カラーリングは簡単で、コードのメンテナンスにはほぼ影響しない。ただ、コードパスを極限まで高速化したい場合、asyncとマークするのは致命的になる。小さなPromisesが積み重なると小さな遅延が生じ、ホットなコードパスではパフォーマンス問題を引き起こすからだ。特に遅延読み込みやキャッシュ処理のように、関数が「時々async」になる場合は本当に頭が痛い。これを避けるには、コールバックを使うか、Promiseが返ってきた時だけ連鎖させるという選択的アプローチが必要になる。どちらもコードが汚くなりやすく、設計判断を理解していないメンバーがハマりやすいポイントだ。
あと面白いことに、indexedDBはPromisesと相性が最悪だ。「タスク終了時にトランザクションを閉じる」というメカニズムがあるせいで、Promiseのタスクシステムとの兼ね合いで、ある種の一般的なパターンが不可能になっている。まあ、API設計者の中には、内部でコールバックを使いつつ、トランザクションごとに1オペレーションだけ行うことで、データベースにPromiseインターフェースを提供する方法を編み出している連中もいるけどね。
彼らの「順次実行の罠」の例は間違っている。
asyncメソッドを呼び出す際、すぐにawaitしなくてもいいんだ。できるだけ遅いタイミングでawaitするように書けば、並列(あるいは設定した通りの方法)で実行されるぞ。