皆さんはJavaScriptの「イベントループ」についてどのくらいご存知でしょうか?
イベントループはJavaScriptランタイムがプログラムを実行するときの基盤となる仕組みです。他の言語やフレームワークにも類似概念がありますが(例:C#のWinForms)、重要さの度合いが異なります。
最近、社内で開催している読書会の課題図書で「イベントループ」について言及する箇所があり、、私自身この仕組みについて概要を理解できていないことに気がついたので少し調べてみました。
似通った関心をお持ちの方にとっての参考になればよいなと思っています。
なるべく簡潔にまとめたつもりですが、、、すみません、ものすごく文字だらけです。私の関心の的になったものをギュウギュウ押し込みました。
JavaScriptプログラムはシングルスレッド
- Webブラウザであれ、Node.jsであれ、JavaScript(JS)ランタイムで実行されるプログラムは原則としてシングルスレッドで実行される。
- 例外として、ブラウザ上のWebWorkerやNode.js上のcluster・child_processなど、非同期プログラミングの仕組みが提供されているが、JavaやC#におけるほど利用は容易ではなく、制限も多い(反面、より安全ではある)。ここではより一般的なテーマであるイベントループに注目。
だいたいはタスク、一部はマイクロタスク
- JSプログラムの多くは「タスク」として実行される。scriptタグで読み込まれたJSコード、イベントリスナー、setTimeout(function, delay)関数に渡された関数──これらは「タスク」としてキューに登録され、先に登録されたもの(古いもの)から順番に、直列に実行される。
- JSプログラムのうち一部のものは「マイクロタスク」(microtask)として実行される。Promise.prototype.then(onFulfilled, onRejected)関数に渡された関数──これらは「マイクロタスク」として専用のキュー(≠タスクのキュー)に登録され、各「タスク」(≠マイクロタスク)が終わるたび、先に登録されたもの(古いもの)から順番にすべて実行される。
1タスク→全マイクロタスク→画面描画
- タスクが1つ完了するたび、(もしマイクロタスクのキューに要素があれば)すべての未実行のマイクロタスクが実行され、その後(もし変更すべきものがあれば)画面描画が更新される。
- すべてのマイクロタスクの実行が終わり、すべての画面描画が終わった段階で、タスクキューの次の要素が実行される。
- あるタスクの実行中に、別のタスクや別のマイクロタスク、画面描画が行われることはない。あるマイクロタスクの実行中に別のマイクロタスクやタスク、画面描画が行われることはない。
- あるタスクの完了後、画面描画が開始されるよりも前に、必ずすべてのマイクロタスクが実行される。
2つのタスクキュー
- つまり、JSのスレッド(Webブラウザのページ読み込みとともに起動したり、Node.jsのプロセス開始とともに起動したりしたJSのスレッド)は、2つのタスクキュー(「タスク」のキューと、「マイクロタスク」のキュー)を運用して、並行(≠並列)なプログラム実行を実現している。
- これはWebブラウザのような実行環境における使用可能なリソース(CPU、メモリ)の制限や、WebサーバーにおけるC10K問題といった問題の克服を念頭においたランタイム設計である。
ブロッキング回避の必要性
- タスクもマイクロタスクもあくまでも1スレッド上で行われているので、あるタスク(ないしあるマイクロタスク)の実行に非常に時間がかかる場合、その他のタスクも含めた滞留が起こってしまう。Webブラウザのランタイムでは画面が固まり、ユーザーの画面操作を受け付けない状態となってしまう。
- 一般にプログラムの中でこうしたボトルネックの原因となるのはI/O(ネットワークを経由したデータのやり取り、ファイルシステムのファイル読み書き、DBとのやり取りなど)。仮に、WebAPIやファイルシステム、DBの側の処理がスムーズに進んだとしても、JSランタイム内で完結する処理に比べると非常に大きな時間を要する。
- このため、Ajaxはもちろんそれを抽象化するHTTPクライアントの実装(例えばAngularのHttpClientModule)も、非同期に実行されるよう設計されている。これらのAPIではI/Oを伴う処理はイベントループの外、つまり別プロセスで行われる。
ノンブロッキング処理の例
- 例えばAjaxリクエスト(XMLHttpRequest)であれば、その処理の状態(完了したかどうか)はタスクから一定間隔で確認したり、予め登録しておいたイベントリスナー(処理が完了=イベントが発火するとタスクキューに追加され、いずれ順番が回って来たらタスクとして実行される)により検知することができる。
- このAjaxリクエストとその完了通知の仕組みについて、AngularJSの$httpサービスはPromiseを使って抽象化し、Angular2~のHttpClientModuleはRxJS(Observableオブジェクト)を使って抽象化している。
組み込みI/O以外の非同期処理
- こうしたI/Oを伴う組み込みの処理とは異なって、開発者が独自に設計・実装した処理で大きな所要時間が見込まれる場合や、mainのスレッドとは独立して一定間隔で処理をさせたりしたい場合はWebWorkerやchild_processなどなど(各ランタイムが提供する)並行プログラミングのAPIを利用する。
- これらのmainのスレッドとは別に立ち上げられたスレッドも、個別のイベントループを持っており、それを土台にしてプログラムを実行する。
イベントループの標準仕様
- イベントループはJSの言語の標準仕様ではなく、ランタイムの標準仕様。
- 標準仕様は当初WHATWGという開発者コミュニティにより「HTML Living Standard」(2004年。最終更新はこの記事を執筆している時点で2021年9月20日)の中で策定され、その後W3CのHTML5に取り込まれた。2021年1月28日以降、W3Cの標準は廃止となり、現在も更新され続けている「HTML Living Standard」が後継となる。
Promiseの補足
- PromiseはJavaScriptの仕様であるEMACScript(エクマスクリプト)のバージョン2015で定義されている非同期処理のためのAPI。
- 「非同期に実行される処理と、その結果得られる値」を表すためのオブジェクトで、JavaのFuture、C#のTaskに相当。
- Promise(非同期処理をラップしている)を同期的なコードと同じように利用できるようにするためECMAScriptのバージョン2017ではasync/awaitも導入された。考え方はC#におけるTaskとasync/awaitの関係と同じ。
RxJSの補足
- サードパティ製ライブラリであるRxJSが提供するObservableオブジェクトは、ある意味でPromiseをさらに一般化したもの。
- Promiseは1回結果が出たら(結果値が1つ手に入ったら)それで終わりになる処理に適用できるが、Observableは結果が1~複数回継続的に得られるものに適用できる。この対比関係はOption(Optional)とArray(Iterable・IEnumerable)の関係に相当。
- RxはReactive Extensionsの略記。ReactiveはReactive Programmingというアプリケーション設計思想を表す。Rxはこの思想に基づき実装されたイベントベース/データ駆動プログラミングのためのライブラリ。MicrosoftによるRx.NETに始まり、Java、Swift、JavaScriptなど各言語の実装が追随した。
参考文献
- MDN Web Docsの「プロミスの使用」のセクション( https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Using_promises )
- 『JavaScript Primer』の「非同期処理」の章( https://jsprimer.net/basic/async/ )
- 『HTML5 Living Standard』の「Event loop processing model」のセクション( https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model )
- 『現代の JavaScript チュートリアル』の「イベントループ(event loop): microtask と macrotask」のセクション( https://ja.javascript.info/event-loop#ref-1558 )