Node.jsを理解する (libuv)
はじめに
最近Node.jsデザインパターンという本を購入して読み理解した内容について社内LTで発表したのでその内容を軽く纏めようと思い、この記事を書きます。
内容としては一章の内容をまとめ、さらに深ぼったといった感じです。
前提
少し不確定な部分があり、誤っている可能性がある箇所はコメントを書いています。
ご存じの方いましたらご教授いただきたいです。
Nodeがなぜこのような思想なのかの話はしません、具体的にはLAMPやc10k問題の話はしません。
他の参照した記事ではイベントループと紐づいているイベントキューにおけるlibuvが提供している部分をマクロタスク、Node.jsが提供している部分をマイクロタスクと書いている記事もありますが、
この記事では hiroppyさんの記事 と同じようにlibuvが提供している部分をフェーズと書いています。
Node.js とは
公式より
Node.js はスケーラブルなネットワークアプリケーションを構築するために設計された非同期型のイベント駆動の JavaScript 環境です。
特徴としては
- シングルスレッド
- ノンブロッキングI / O + libuv = 非同期I / O
が挙げられます。
Node.jsのアーキテクチャは以下のようになっていて今回は赤丸で囲まれているlibuvについて学んでいきたいと思います。
スレッド
スレッドとはプログラムの処理の実行単位の一つであり、タスクやプロセスより細かい処理の実行単位のことです。
シングルスレッドは文字通りスレッド一つでプログラムを処理することです。
マルチスレッドでは複数スレッドで処理を分散させることができますが、シングルスレッドではそれができないのでどうにかして処理を分散させる必要があります。
結論としては時間軸で分散させるわけですが、それを実現するために非同期I / Oを使用しています。
ノンブロッキングI / O, 非同期I / O
ノンブロッキングI / O
- I / O処理が走った時にI / O処理ができない場合は即座にエラーを返しブロックさせない方式
- データが処理可能になるまでリクエストを送る必要がある
非同期I /O
- I / O処理が走った時に処理が完了するまでバックグラウンドで待機しI / O処理が完了したタイミングで通知を返すことによってブロックしない方式
この説明だけ見ると非同期I / Oのほうがどう見ても優れています。
ノンブロッキングI / OでI / O処理を行おうと思うとI / Oの完了を検知してそれからI / O処理を行う必要がありそうです。
Busy Wait
ノンブロッキングI / Oの処理方法の一つに Busy Wait
というものがあります。
これはI / Oの処理ができるようになるまでループを回してポーリングする方法のことです。
ただしこの方法ではI / O処理可能になるまでループするためCPUを食うことになります。
while(!resource.isEmpty()) {
data = resource.read();
if(data === "") {
continue;
} else {
doSomething(data);
};
};
Reactor Pattern
そこでNode.jsでは非同期I / Oを実現するために Reactor Pattern
を使用しています。
上記したようにI / Oに基づいた処理はI / O完了の通知を受け取ってからデータを取り出し行う必要があります。
let input;
require('fs').readFile('sample.txt', (err, data) => {
input = data;
});
console.log(input);
この場合は data
が読み込み完了する前に console.log(input);
に到達してしまうため、何も出力されません。
Reactor PatternではシングルスレッドでI / O処理を行うために
- ノンブロッキングI / O
- イベント多重分離(Event demultiplexing)
- イベントループ
を用いています。
イベント多重分離(event demultiplexing)
一つにまとまった信号(処理)を複数に分離することをデマルチプレキシング(多重分離)と言います。
この機能はOSに提供されていて、I / Oの完了が完了した時に〇〇するができるようになっています。
この方法を使用することによって時間軸で処理を分散させシングルスレッドでも並行に処理を実行することができます。
libuv
上記したデマルチプレクサの実装はOS毎に異なっています(Linuxのepoll、macOSのkqueueなど)。
このOS間の差分を吸収し抽象化するために、Node.jsのコアチームは libuv というCのライブラリを作成しました。
このlibuvがこの後紹介する イベントループ 、 非同期処理 をNode.jsに提供しています。
Reactor PatternでのI / Oリクエスト時の実際の動き
実際にReactor PatternでI / Oリクエストが来た時の処理の流れを表したのが上の図です。
基本的にはI / Oタスクのそれぞれにハンドラ(Node.jsではコールバック)を対応させ、イベントループにおいて新イベントが生成・処理されるたびにハンドラが呼び出されます。
この図のアプリケーション以外の部分がlibuvによって提供されています。
順番に見ていきます、記法と拝借した図の割り振られている番号で見づらいかもですがご了承ください。
-
アプリがデマルチプレクサに対してI / O要求を発行する
その際にハンドラが指定され、I / O要求の発行はノンブロッキングな関数呼び出しなので即座にアプリケーションに処理が戻る -
I / O要求が届くとデマルチプレクサがイベントキューに入れる
-
イベントループにおいて、イベントキューの中の全てのイベントが操作されて処理される
-
各イベントに対して、登録済みのハンドラが呼び出される
-
5a のことだと思ってください: ハンドラの呼び出しが完了するとイベントループで次のイベントが処理される
-
5b のことだと思ってください: ハンドラ内で更にI / O要求が発行された場合はイベントループに処理を戻す前に1の処理を繰り返す
-
6 のことだと思ってください: イベントループで全てのイベントが処理されると新しいイベントが送られてくるまで待機する
イベントキュー
libuvから提供されるキューとNodeが提供するキューがあります。
libuv
- Expired timers / intervals queue
- IO Events Queue
- Immediates Queue
- Close Handlers Queue
Node.js
- nextTick Queue
- microTask Queue
libuvが提供しているキューはイベントループの各フェーズに紐づいており、フェーズが実行される毎にNode.jsが提供しているキューが実行されます。
イベントループ
イベントループには6つ(idleとprepareを分けると7つ)のフェーズがあり、Reactor Patternの図で言う 3・4・5a がこの6つの順番で行われます。
それぞれのフェーズは実行するコールバックのキューをもち、JavaScriptの実行はidle,prepare(pollも?)以外のどこかのフェーズで実行され、キューが空になるかコールバックの上限に達したらイベントループは次のフェーズへ遷移します。
イベントループとイベントキューの対応表
画像のようにlibuvは各フェーズ毎に結果をJavaScriptに伝える、この時に nextTickQueue
と microTaskQueue
に入れられた内容を処理する
重複を気にせずに展開すると以下のようになります
先にNode.jsによって提供されているものについて説明します。
nextTickQueue
process.nextTickのコールバックが実行されます。
非同期処理の中で最初に実行されます。
process.nextTick(() => console.log('nextTick'));
microTaskQueue
Promiseオブジェクトのコールバックが実行されます。
Promise.resolve().then(() => console.log('promise'));
順序的にはnextTickQueueが先に実行されるので以下のようになります。
process.nextTick(() => console.log('1'));
Promise.resolve().then(() => console.log('2'));
process.nextTick(() => console.log('3'));
Promise.resolve().then(() => console.log('4'));
1
3
2
4
ここからはイベントループにおけるそれぞれのフェーズについて説明します
Timer
setTimer, setIntervalなどのタイマー系APIの期限切れコールバックが実行されます。
setTimer(() => console.log('setTimer'));
setInterval(() => console.log('setInterval'));
Pending
I / O操作の成功、エラーのコールバック関数が実行されます。
import fs from 'fs';
fs.readFile('sample.txt', (err, data) => {
if (err) console.log(err);
console.log(data);
});
Idle, Prepare, Poll
オプショナルなフェーズでpollフェーズが行われる場合はidle / prepareフェーズが行われます。
I / O をブロックしてポーリングする時間を計算します。
Check
setImmediateのコールバック専用のフェーズでsetImmediateで登録された全てのコールバックを実行します。
setImmediate(() => console.log('setImmediate'))
close
全てのcloseフェーズのコールバックが実行されます。
import fs from 'fs';
const readStream = fs.createReadStream('sample.txt');
readStream.on('close', (err) => {
console.log(err);
});
実際に叩いてみると
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
process.nextTick(() => console.log(3));
Promise.resolve().then(() => console.log(4));
console.log(5);
require('fs').readFile('sample.txt', (err, data) => {
console.log(6);
});
5
3
4
1
6
2
まとめ
-
Node.jsは
libuv + ノンブロッキング I / O = 非同期 I / O
を使うことでI / O 待ちの時間を時間軸に分散している -
イベントループ、I / O完了通知などの根幹の部分はOS差分を吸収するために
libuv
というライブラリを使用している
最後に
最後の方は他の方の書いたことをまとめた感じになってしまったのでアレですが、自分の中ではlibuv周りのことについて知り頭の中で整理することができたのでとてもよかったです。
今回はNode.jsのアーキテクチャの中でlibuvについてでした、ブラウザだとユーザーランドとの間にレンダリングエンジンが挟まっていたりして、サーバー側と差があるのでその辺りかChromeでも使われているV8について書けたらいいなと考えています。
誤っている部分ありましたら、コメントいただけると助かります。
参考資料
- https://www.oreilly.co.jp/books/9784873118734/
- https://blog.insiderattack.net/event-loop-and-the-big-picture-nodejs-event-loop-part-1-1cb67a182810
- https://zenn.dev/estra/books/js-async-promise-chain-event-loop
- https://blog.takanabe.tokyo/2015/03/ノンブロッキングi/oと非同期i/oの違いを理解する/
- https://blog.hiroppy.me/entry/nodejs-event-loop
- https://engineer.recruit-lifestyle.co.jp/techblog/2019-12-13-node-async-io/
- https://nodejs.org/en/about/
- https://libuv.org/
Discussion