NOTE: この記事は、弊社内のオンボーディング資料を公開したものです。
- 他言語学習者向け:JavaScript/ECMAScript文法速習リファレンス
- 他言語学習者向け:JavaScript/ECMAScriptプロトタイプ及びクラス入門
- NodeJS入門: イベントループとlibuv編
NodeJSについて学ぶべき要素
この記事では、NodeJSに関してより具体的に学ぶ資料です。
- イベントループとlibuv
- V8エンジンとメモリライフサイクル
- コアモジュール
イベントループとlibuv
NodeJSの革新性は、従来、シングルスレッドでwhileループを回してIO待ちを行っていた処理 を JavaScriptの世界に存在していたイベントループの概念 に統合し、イベントドリブンなIO処理を容易に記述できるようにした点にあります。
ここでは、それらを理解するのに必要な要素を学習します。
はじめに
イベントループとlibuvは、NodeJSの根幹をなす重要な概念とライブラリです。とはいえ、すでにNodeJS上のエコシステムが充実した現在、イベントループを意識してコードを書くことは、低レイヤーでもない限り稀でしょう。
しかし、なぜNodeJSがコールバックを主体としたランタイムAPIを多数持っているのか、シングルスレッドでどのようにIOアクセスが行われているのか、あるいは、EventEmitterのイベントハンドラはどの順番で呼び出されるのかなどを考えるとき、これらの理解が役立つはずです。
詳細h、NodeJS本体のソースコードをよんでいただくとして、ここでは簡単な説明で理解を図ることにします。
イベントループ
NodeJSの最大の特徴の1つが、イベントループです。
現代のJavaScriptとキュー
そもそも、現代のJavaScriptエンジンは、イベントループに基づく同時実行モデルが備わっており、通常のスタックとヒープ領域に加え、キューと呼ばれる領域にメッセージキューを格納し、順次、メッセージ処理する仕組みを基本としています。
出典:https://developer.mozilla.org/ja/docs/Web/JavaScript/Event_loop
これは、本来、JavaScriptがブラウザ上でイベントドリブンなインタラクションを実現するための処理系であったことが起源となっています。
イベントループモデルとは
イベントループでは
- キューを古い順番に取り出す
- キューのメッセージをパラメータにし、紐づいているコールバック関数を呼び出す
- スタックが構成され、コールバック関数の処理が開始される
- スタックが空になり、関数が完了したら次のキューを処理する
という順序で処理が行われます。
例えば、ユーザーがボタンをクリックしたとき、クリックイベントはキューに格納されます。ボタンに紐づくイベントリスナーが存在すると、それらをスタックに展開して関数を実行する、というようなイメージです。
PubSubモデルやメッセージ指向のようなものに近いと考えてもらって差し支えありません。
JavaScriptエンジンだけでなく、WindowsアプリケーションやXlib、ゲームプログラミングなど、インタラクティブ性の高い処理にはイベントループモデルが採用されています。
NodeJSとイベントループ:ノンブロッキング
イベントループモデルがもたらす利点の1つが、待ち時間のある処理をイベントとコールバックの形で表現することで、ノンブロッキング化できる点にあります。
子プロセスやスレッドによって待ち時間を他のコンテキストで処理するのではなく、メッセージキューとして積んでおき、その間はメインのプログラムを実行することで、プログラムを待たせる=ブロックする必要がなくなります。
これを、IO(ディスクやネットワーク)に対して広く実現したものが、NodeJSです。
多くのIO呼び出しは、プログラム実行よりはるかに待ち時間が長く処理が遅いものです。NodeJSでは、IOの処理をイベントループモデルに統合することで、シングルスレッドかつノンブロッキングな処理を実現した異様なプラットフォームであるともいえます。
NodeJSにおける具体的なイベントループ
NodeJSのイベントループは、次のように表現されます。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
このイベントループは、上記に表現される各フェイズが順番に実行されていきます。
それぞれのフェイズに、キューが存在しており、キューを捌き切るか、コールバックの最大数に達すると、次のフェイズに移行します。
フェイズ | 概要 |
---|---|
timers |
setTimetout() などタイマーによってスケジュールされたコールバックがキューに入っています。指定した時間以上が経過した場合にのみ、実行されます。 |
pending callbacks | コールバック最大数を超えたなどの理由で前回実行が延期されたコールバックが実行されます |
idle, prepare | 内部用のフェイズです |
poll | IOイベントを取得し、IO関連のコールバックを実行します |
check |
setImmediate() などを実行します |
close callbacks |
socket.on('close') などクローズ系のコールバックを実行します |
pollフェイズ(ポーリングフェイズ)の仕組み
ポーリングフェイズ(poll
)はやや特殊なフェイズです。ここがネットワーク接続やディスクなどのIOを担っている部分であり、IOイベントに基づくコールバックのほとんどがここで実行されます。
ポーリングフェイズでは、その名前の通り、IOをポーリングしており、カーネルから届くIOイベントを待っています。もし、check
や timers
のキューに特に何も入っていなければ、ポーリングフェイズで待機し続けることになります。
実行順序の違い:setTimeout VS setImmediate
timersフェイズ(setTimeout系)とcheckフェイズ(setImmediate系)は、ポーリングフェイズの前後に設置されています。
ポーリングフェイズでキューが空になった場合、次の2つのケースが想定されます。
-
timersフェイズにキューが入った場合
→コールバックを実行すべきタイミングになったら、ポーリングフェイズを抜けます -
checkフェイズにキューが入った場合
→ポーリングフェイズがアイドル状態(キューがない状態)になった直後にcheckフェイズに移ります。
イベントループの順番は決して後進することはないため、ポーリングフェイズでのコールバック直後では、timersよりcheckが優先されます。
というわけで、通常、setTimeoutに対して、setImmediateが早く実行されます。
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
// immediate
// timeout
ただし、これらはイベントループの内部での話であるため、メインのコード上でsetTimeoutとsetImmediateを記述した場合、どちらが先に実行されるかはそのホストの状態によります。
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
// 0秒後が先に来るか、ポーリングフェイズのアイドルが先に来るかは、マシンの状態による
nextTickQueueとmicroTaskQueue
各フェイズのコールバックの完了後に参照するnextTickQueueとmicroTaskQueueという2つの特別なキューが存在します。
どちらもNodeJS本体のキューであり、イベントループの外側に存在する非同期APIです。
┌───────────────────────────┐
┌─>│ timers │
│ │ [callback, callback, ...] │
│ │ \`-│==> [nextTickQueue ]
│ │ `-│==> [microTaskQueue]
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
process.nextTick()
process.nextTick()
は、nextTickQueueにコールバックを登録するAPIです。
nextTick()
がコールされると、イベントループのどのフェイズにいるかに関わらず、現在の処理(C/C++ハンドラからの遷移またはJSの処理)が完了次第実行されます。イベントループが進む前にコールバックの実行が行われるわけです。
nextTickで登録したコールバックは、メインコードのスタックではないため、ある種の遅延実行とも言えます。
この特性を活かすことで、非同期的な処理を伴うAPIコールを行った際のエラー処理やクリーンアップ処理を実行させることができます。
とはいえ、現代のNodeJSではprocess.nextTick()
を直接用いてコードを書くことは稀で、公式ドキュメントでも setImmediate()
を利用することを推奨しています。
Promise
一方、microTaskQueueは、主にPromiseの解決に用いられます。
Promiseオブジェクトのthen
メソッドで登録されたコールバックを実行します。
Promiseはイベントループのサイクルよりも早く解決されるわけですね。
libuv
さて、ポーリングフェイズでは、IOのイベントを待つことを説明しました。
このIOイベントを取りまとめ、イベントループを実現しているのが、NodeJSで採用されている libuv
です。
libuv
は、
を踏まえ、さらにプラットフォーム(OS)問わずに実行できるようにしたイベントループのライブラリです。
libuvは何をしているのか
非同期イベント駆動型プログラミングを実現するためのイベントループの提供とそのためのIO関連の通知を取りまとめています。
イベントループ
#include <stdio.h>
#include <uv.h>
int main() {
uv_loop_t *loop = uv_default_loop();
printf("Default loop.\n");
uv_run(loop, UV_RUN_DEFAULT);
uv_loop_close(loop);
return 0;
}
この uv_default_loop()
によってイベントループが提供されています。(通常、デフォルトループが唯一のイベントループであり、シングルスレッドで動作します)。
スレッド
さらに、libuvはスレッド機能を持っています。
pthreads APIに似たセマンティクスを提供します。
NodeJSでは、デフォルトで4つのスレッドプールを用意していることが知られています。
過去には最大128スレッドだったのですが、現在は1024まで増えたようです。
ワークキュー
先ほどのNodeJSのフェイズに対応するキューを提供するのがlibuvのワークキュー機能です。
uv_queue_work
によって提供されます。
int uv_queue_work(uv_loop_t *loop, uv_work_t *req, uv_work_cb work_cb, uv_after_work_cb after_work_cb)
このワークキューは、イベントループ、ワークリクエスト(データ)、コールバック、完了後コールバックをセットすることができます。これにより、イベントループの外側のスレッドで実際の処理が実行され、イベントループそのものは高速に回転し続けることができます。
NodeJSはlibuvのこのワークキューを用いることで、さまざまな機能をイベントループパラダイムに統合しているわけです。
スレッドプールでは、IOアクセスのほか、OpenSSLによって提供される暗号化処理やZlib、子プロセスの制御などが実行されています。
IOの抽象化
NodeJSを強力なプラットフォームに変えた要因の1つが、libuvによるIOの抽象化です。
libuvは、Linux/UNIX/Windowsを問わず、ディスクやネットワークのIOにアクセスすることができます。
言わずもがな、各プラットフォームでディスクやネットワークへのアクセス方法は異なります。そこで、libuvでは、各種機能を統合しています。
- libev/libio
- epoll (Linux)
- kqueue (BSD)
- event port (Solaris)
- c-ares (DNS)
- IOCP (Windows)
ただし、WindowsのIOCPとepollではネットワークでの振る舞いが異なったり、プロセスシグナルの差などもあり、完全に相互互換という形にはなっていません。そのため、NodeJS上でもプラットフォーム差が出てしまう機能もいくつかあります。
プロセスやシグナルの話は別のところでまた話します。
まとめ
NodeJSでは、libuvによって提供されるシングルスレッドのイベントループとスレッドプールを駆使して、IOや重いタスクを高効率で実行しています。