Node.jsを理解する (libuv)

2022/06/26に公開

はじめに

最近Node.jsデザインパターンという本を購入して読み理解した内容について社内LTで発表したのでその内容を軽く纏めようと思い、この記事を書きます。
内容としては一章の内容をまとめ、さらに深ぼったといった感じです。

前提

少し不確定な部分があり、誤っている可能性がある箇所はコメントを書いています。
ご存じの方いましたらご教授いただきたいです。
Nodeがなぜこのような思想なのかの話はしません、具体的にはLAMPやc10k問題の話はしません。

他の参照した記事ではイベントループと紐づいているイベントキューにおけるlibuvが提供している部分をマクロタスク、Node.jsが提供している部分をマイクロタスクと書いている記事もありますが、
この記事では hiroppyさんの記事 と同じようにlibuvが提供している部分をフェーズと書いています。

Node.js とは

公式より

Node.js はスケーラブルなネットワークアプリケーションを構築するために設計された非同期型のイベント駆動の JavaScript 環境です。
特徴としては

  • シングルスレッド
  • ノンブロッキングI / O + libuv = 非同期I / O
    が挙げられます。

Node.jsのアーキテクチャは以下のようになっていて今回は赤丸で囲まれているlibuvについて学んでいきたいと思います。
Node.jsのアーキテクチャの図

スレッド

スレッドとはプログラムの処理の実行単位の一つであり、タスクやプロセスより細かい処理の実行単位のことです。
シングルスレッドは文字通りスレッド一つでプログラムを処理することです。

マルチスレッドでは複数スレッドで処理を分散させることができますが、シングルスレッドではそれができないのでどうにかして処理を分散させる必要があります。
結論としては時間軸で分散させるわけですが、それを実現するために非同期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の画像

実際にReactor PatternでI / Oリクエストが来た時の処理の流れを表したのが上の図です。
基本的にはI / Oタスクのそれぞれにハンドラ(Node.jsではコールバック)を対応させ、イベントループにおいて新イベントが生成・処理されるたびにハンドラが呼び出されます。
この図のアプリケーション以外の部分がlibuvによって提供されています。

順番に見ていきます、記法と拝借した図の割り振られている番号で見づらいかもですがご了承ください。

  1. アプリがデマルチプレクサに対してI / O要求を発行する
    その際にハンドラが指定され、I / O要求の発行はノンブロッキングな関数呼び出しなので即座にアプリケーションに処理が戻る

  2. I / O要求が届くとデマルチプレクサがイベントキューに入れる

  3. イベントループにおいて、イベントキューの中の全てのイベントが操作されて処理される

  4. 各イベントに対して、登録済みのハンドラが呼び出される

  5. 5a のことだと思ってください: ハンドラの呼び出しが完了するとイベントループで次のイベントが処理される

  6. 5b のことだと思ってください: ハンドラ内で更にI / O要求が発行された場合はイベントループに処理を戻す前に1の処理を繰り返す

  7. 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に伝える、この時に nextTickQueuemicroTaskQueue に入れられた内容を処理する

重複を気にせずに展開すると以下のようになります
イベントループの実行を展開した画像

先に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について書けたらいいなと考えています。
Node.jsのアーキテクチャの図

誤っている部分ありましたら、コメントいただけると助かります。

参考資料

Discussion