Node.jsでのシェルスクリプト
この書籍のオフライン版(HTML、PDF、EPUB、MOBI)をご購入いただくと、無料のオンライン版をサポートできます。
(広告、ブロックしないでください。)

4 Node.jsの概要:アーキテクチャ、API、イベントループ、並行処理



この章では、Node.jsの動作の概要を説明します。

4.1 Node.jsプラットフォーム

次の図は、Node.jsの構成の概要を示しています。

Node.jsアプリで利用できるAPIは、以下で構成されています。

Node.js APIは、部分的にJavaScriptで、部分的にC++で実装されています。後者は、オペレーティングシステムとインターフェースするために必要です。

Node.jsは、埋め込みのV8 JavaScriptエンジン(Google Chromeブラウザで使用されているものと同じエンジン)を介してJavaScriptを実行します。

4.1.1 グローバルNode.js変数

以下は、Nodeのグローバル変数のいくつかのハイライトです。

この章全体で、さらに多くのグローバル変数が紹介されています。

4.1.1.1 グローバル変数の代わりにモジュールを使用する

次の組み込みモジュールは、グローバル変数の代替を提供します。

原則として、モジュールを使用する方がグローバル変数を使用するよりもクリーンです。ただし、グローバル変数consoleprocessの使用は確立されたパターンであるため、それらから逸脱することにも欠点があります。

4.1.2 組み込みNode.jsモジュール

NodeのAPIのほとんどは、モジュールを通じて提供されます。以下は、頻繁に使用されるいくつかのモジュールです(アルファベット順)。

モジュール'node:module'には、すべての組み込みモジュールの指定子を含む配列を返す関数builtinModules()が含まれています。

import * as assert from 'node:assert/strict';
import {builtinModules} from 'node:module';
// Remove internal modules (whose names start with underscores)
const modules = builtinModules.filter(m => !m.startsWith('_'));
modules.sort();
assert.deepEqual(
  modules.slice(0, 5),
  [
    'assert',
    'assert/strict',
    'async_hooks',
    'buffer',
    'child_process',
  ]
);

4.1.3 Node.js関数の異なるスタイル

このセクションでは、次のimportを使用します。

import * as fs from 'node:fs';

Nodeの関数には、3つの異なるスタイルがあります。例として組み込みモジュール'node:fs'を見てみましょう。

先ほど見た3つの例は、同様の機能を持つ関数の命名規則を示しています。

これらの3つのスタイルがどのように機能するかを詳しく見てみましょう。

4.1.3.1 同期関数

同期関数は最も単純で、値をすぐに返し、エラーを例外としてスローします。

try {
  const result = fs.readFileSync('/etc/passwd', {encoding: 'utf-8'});
  console.log(result);
} catch (err) {
  console.error(err);
}
4.1.3.2 Promiseベースの関数

Promiseベースの関数は、結果で解決され、エラーで拒否されるPromiseを返します。

import * as fsPromises from 'node:fs/promises'; // (A)

try {
  const result = await fsPromises.readFile(
    '/etc/passwd', {encoding: 'utf-8'});
  console.log(result);
} catch (err) {
  console.error(err);
}

A行のモジュール指定子に注意してください。PromiseベースのAPIは別のモジュールにあります。

Promiseについては、「JavaScript for impatient programmers」で詳しく説明されています。

4.1.3.3 コールバックベースの関数

コールバックベースの関数は、結果とエラーを最後のパラメータであるコールバックに渡します。

fs.readFile('/etc/passwd', {encoding: 'utf-8'},
  (err, result) => {
    if (err) {
      console.error(err);
      return;
    }
    console.log(result);
  }
);

このスタイルについては、Node.jsドキュメントで詳しく説明されています。

4.2 Node.jsイベントループ

デフォルトでは、Node.jsはすべてのJavaScriptをシングルスレッド、つまりメインスレッドで実行します。メインスレッドは、JavaScriptのチャンクを実行するループであるイベントループを継続的に実行します。各チャンクはコールバックであり、協調的にスケジュールされたタスクと見なすことができます。最初のタスクには、Node.jsを起動するコード(モジュールまたは標準入力から)が含まれています。他のタスクは通常、次の理由で後で追加されます。

イベントループの最初の近似は次のようになります。

つまり、メインスレッドは次のようなコードを実行します。

while (true) { // event loop
  const task = taskQueue.dequeue(); // blocks
  task();
}

イベントループは、タスクキューからコールバックを取り出し、メインスレッドで実行します。タスクキューが空の場合、デキューはブロック(メインスレッドを一時停止)します。

後で2つのトピックを探求します。

なぜこのループがイベントループと呼ばれるのでしょうか?多くのタスクはイベントに応じて追加されます。たとえば、入力データを処理する準備ができたときにオペレーティングシステムから送信されるイベントなどです。

タスクキューにコールバックはどのように追加されるのでしょうか?以下が一般的な可能性です。

次のコードは、非同期コールバックベースの操作の例を示しています。ファイルシステムからテキストファイルを読み取ります。

import * as fs from 'node:fs';

function handleResult(err, result) {
  if (err) {
    console.error(err);
    return;
  }
  console.log(result); // (A)
}
fs.readFile('reminder.txt', 'utf-8',
  handleResult
);
console.log('AFTER'); // (B)

これは出力結果です。

AFTER
Don’t forget!

fs.readFile() は、別のスレッドでファイルを読み取るコードを実行します。この場合、コードは成功し、このコールバックをタスクキューに追加します。

() => handleResult(null, 'Don’t forget!')

4.2.1 完了まで実行することでコードが簡素化される

Node.jsがJavaScriptコードを実行する上で重要なルールは、各タスクは他のタスクが実行される前に「完了まで実行」されるということです。前の例でそれを見ることができます。初期タスクが handleResult() の呼び出しを含むタスクが実行される前に完了するため、B行の 'AFTER' がA行の結果がログに記録される前にログに記録されます。

完了まで実行するということは、タスクのライフタイムが重複せず、バックグラウンドで共有データが変更されることを心配する必要がないということです。これにより、Node.jsのコードが簡素化されます。次の例はそれを実証します。シンプルなHTTPサーバーを実装します。

// server.mjs
import * as http from 'node:http';

let requestCount = 1;
const server = http.createServer(
  (_req, res) => { // (A)
    res.writeHead(200);
    res.end('This is request number ' + requestCount); // (B)
    requestCount++; // (C)
  }
);
server.listen(8080);

このコードは、node server.mjsで実行します。その後、コードが起動し、HTTPリクエストを待ちます。ウェブブラウザを使用してhttps://:8080にアクセスすることで、リクエストを送信できます。そのHTTPリソースをリロードするたびに、Node.jsはA行で開始されるコールバックを呼び出します。コールバックは、変数requestCountの現在の値(B行)を含むメッセージを処理し、それをインクリメントします(C行)。

コールバックの各呼び出しは新しいタスクであり、変数requestCountはタスク間で共有されます。完了まで実行されるため、読み取りと更新が簡単です。同時に実行されている他のタスクと同期する必要はありません。なぜなら、同時に実行されているタスクがないからです。

4.2.2 なぜNode.jsのコードはシングルスレッドで実行されるのか?

なぜNode.jsのコードは(イベントループを使用して)デフォルトでシングルスレッドで実行されるのでしょうか?それには2つの利点があります。

Nodeの一部の非同期操作がメインスレッド以外のスレッドで実行され(これについては後述します)、タスクキューを介してJavaScriptに報告されることを考えると、Node.jsは実際にはシングルスレッドではありません。代わりに、同時かつ非同期に実行される操作(メインスレッド内)を調整するためにシングルスレッドを使用します。

これで、イベントループの最初の概要は終わりです。もし表面的な説明で十分な場合は、このセクションの残りの部分をスキップしてください。さらに詳しく知りたい場合は読み進めてください。

4.2.3 実際のイベントループには複数のフェーズがある

実際のイベントループには、複数のフェーズで読み込む複数のタスクキューがあります(GitHubリポジトリnodejs/nodeにあるJavaScriptコードの一部を確認できます)。次の図は、これらのフェーズのうち最も重要なものを示しています。

図に示されているイベントループのフェーズは何をするのでしょうか?

各フェーズは、キューが空になるか、最大タスク数が処理されるまで実行されます。「poll」を除き、各フェーズは、実行中に追加されたタスクを処理する前に、次のターンまで待ちます。

4.2.3.1 フェーズ「poll」

このフェーズがシステム依存の時間制限よりも長くかかると、終了し、次のフェーズが実行されます。

4.2.4 next-tickタスクとマイクロタスク

呼び出された各タスクの後、「サブループ」が実行されます。これは2つのフェーズで構成されます。

サブフェーズは次のものを処理します。

next-tickタスクはNode.js固有であり、マイクロタスクはクロスプラットフォームのWeb標準です(MDNのサポートテーブルを参照)。

このサブループは、両方のキューが空になるまで実行されます。実行中に追加されたタスクはすぐに処理されます。サブループは次のターンまで待機しません。

4.2.5 タスクを直接スケジュールするさまざまな方法の比較

次の関数とメソッドを使用して、いずれかのタスクキューにコールバックを追加できます。

遅延を介してタスクのタイミングを設定する場合、タスクが実行される最も早い時間を指定していることに注意することが重要です。Node.jsは、タスク間に時間指定タスクの期限が来ているかどうかを確認できるだけであり、常にスケジュールされた時間に正確に実行できるわけではありません。したがって、実行時間の長いタスクにより、時間指定タスクが遅れる可能性があります。

4.2.5.1 next-tickタスクとマイクロタスク vs. 通常のタスク

次のコードについて考えます。

function enqueueTasks() {
  Promise.resolve().then(() => console.log('Promise reaction 1'));
  queueMicrotask(() => console.log('queueMicrotask 1'));
  process.nextTick(() => console.log('nextTick 1'));
  setImmediate(() => console.log('setImmediate 1')); // (A)
  setTimeout(() => console.log('setTimeout 1'), 0);
  
  Promise.resolve().then(() => console.log('Promise reaction 2'));
  queueMicrotask(() => console.log('queueMicrotask 2'));
  process.nextTick(() => console.log('nextTick 2'));
  setImmediate(() => console.log('setImmediate 2')); // (B)
  setTimeout(() => console.log('setTimeout 2'), 0);
}

setImmediate(enqueueTasks);

ESMモジュールの特殊な点を回避するために、setImmediate()を使用します。ESMモジュールはマイクロタスクで実行されるため、ESMモジュールのトップレベルでマイクロタスクをキューに入れると、next-tickタスクの前に実行されます。次に示すように、これは他のほとんどのコンテキストとは異なります。

これは前のコードの出力です。

nextTick 1
nextTick 2
Promise reaction 1
queueMicrotask 1
Promise reaction 2
queueMicrotask 2
setTimeout 1
setTimeout 2
setImmediate 1
setImmediate 2

考察

4.2.5.2 next-tickフェーズとマイクロタスクフェーズでのnext-tickタスクとマイクロタスクのキューイング

次のコードでは、next-tickフェーズ中にnext-tickタスクをキューに入れ、マイクロタスクフェーズ中にマイクロタスクをキューに入れるとどうなるかを調べます。

setImmediate(() => {
  setImmediate(() => console.log('setImmediate 1'));
  setTimeout(() => console.log('setTimeout 1'), 0);

  process.nextTick(() => {
    console.log('nextTick 1');
    process.nextTick(() => console.log('nextTick 2'));
  });

  queueMicrotask(() => {
    console.log('queueMicrotask 1');
    queueMicrotask(() => console.log('queueMicrotask 2'));
    process.nextTick(() => console.log('nextTick 3'));
  });
});

これは出力です。

nextTick 1
nextTick 2
queueMicrotask 1
queueMicrotask 2
nextTick 3
setTimeout 1
setImmediate 1

考察

4.2.5.3 イベントループフェーズの枯渇

次のコードでは、どの種類のタスクがイベントループフェーズを枯渇させる(無限再帰によって実行を防ぐ)ことができるかを探ります。

import * as fs from 'node:fs/promises';

function timers() { // OK
  setTimeout(() => timers(), 0);
}
function immediate() { // OK
  setImmediate(() => immediate());
}

function nextTick() { // starves I/O
  process.nextTick(() => nextTick());
}

function microtasks() { // starves I/O
  queueMicrotask(() => microtasks());
}

timers();
console.log('AFTER'); // always logged
console.log(await fs.readFile('./file.txt', 'utf-8'));

「timers」フェーズとimmediateフェーズは、フェーズ中に追加されたタスクを実行しません。そのため、timers()immediate()は、「poll」フェーズ中に報告するfs.readFile()を枯渇させません(Promiseリアクションもありますが、ここでは無視しましょう)。

next-tickタスクとマイクロタスクがどのようにスケジュールされるかにより、nextTick()microtasks()の両方が最終行の出力を防ぎます。

4.2.6 Node.jsアプリが終了するのはいつか?

イベントループの各反復の終わりに、Node.jsは終了する時間かどうかを確認します。保留中のタイムアウト(時間指定タスクの場合)の参照カウントを保持します。

イベントループの反復の終わりに参照カウントがゼロの場合、Node.jsは終了します。

次の例でそれを見ることができます。

function timeout(ms) {
  return new Promise(
    (resolve, _reject) => {
      setTimeout(resolve, ms); // (A)
    }
  );
}
await timeout(3_000);

Node.jsは、timeout()によって返されたPromiseが履行されるまで待機します。なぜでしょうか?A行でスケジュールしたタスクがイベントループを維持しているためです。

対照的に、Promiseを作成しても参照カウントは増加しません。

function foreverPending() {
  return new Promise(
    (_resolve, _reject) => {}
  );
}
await foreverPending(); // (A)

この場合、A行のawait中に実行は一時的にこの(メイン)タスクから離れます。イベントループの終わりに、参照カウントはゼロになり、Node.jsは終了します。ただし、終了は成功しません。つまり、終了コードは0ではなく、13(「未完了のトップレベルAwait」)です。

タイムアウトがイベントループを維持するかどうかを手動で制御できます。デフォルトでは、setImmediate()setInterval()、およびsetTimeout()を介してスケジュールされたタスクは、保留されている限り、イベントループを維持します。これらの関数は、クラスTimeoutのインスタンスを返し、そのメソッド.unref()は、タイムアウトがアクティブであってもNode.jsが終了するのを妨げないように、そのデフォルトを変更します。メソッド.ref()はデフォルトを復元します。

Tim Perryは、.unref()の使用例を述べています。彼のライブラリは、バックグラウンドタスクを繰り返し実行するためにsetInterval()を使用しました。そのタスクはアプリケーションが終了するのを妨げました。彼は.unref()を使用して問題を修正しました。

4.3 libuv: Node.jsの非同期I/O(およびその他)を処理するクロスプラットフォームライブラリ

libuvは、多くのプラットフォーム(Windows、macOS、Linuxなど)をサポートするCで記述されたライブラリです。Node.jsは、I/Oなどを処理するためにこれを使用します。

4.3.1 libuvが非同期I/Oを処理する方法

ネットワークI/Oは非同期であり、現在のスレッドをブロックしません。このようなI/Oには、次のようなものがあります。

非同期I/Oを処理するために、libuvはネイティブカーネルAPIを使用し、I/Oイベントをサブスクライブします(Linuxではepoll、macOSを含むBSD Unixではkqueue、SunOSではイベントポート、WindowsではIOCP)。その後、それらが発生すると通知を受け取ります。I/O自体を含むこれらすべてのアクティビティは、メインスレッドで発生します。

4.3.2 libuvがブロッキングI/Oを処理する方法

一部のネイティブI/O APIはブロッキング(非同期ではない)です。たとえば、ファイルI/Oや一部のDNSサービスです。libuvは、スレッドプールのスレッド(いわゆる「ワーカプール」)からこれらのAPIを呼び出します。これにより、メインスレッドはこれらのAPIを非同期的に使用できるようになります。

4.3.3 I/Oを超えたlibuvの機能

libuvは、I/Oだけでなく、Node.jsをさらに支援します。その他の機能には、次のものがあります。

ちなみに、libuvには独自のイベントループがあり、そのソースコードはGitHubリポジトリlibuv/libuv関数uv_run())で確認できます。

4.4 ユーザーコードでメインスレッドからエスケープする

Node.jsがI/Oに応答できるようにするには、メインスレッドタスクで実行時間の長い計算を実行しないようにする必要があります。これを行うには2つのオプションがあります。

次のサブセクションでは、オフロードのいくつかのオプションについて説明します。

4.4.1 ワーカー スレッド

ワーカー スレッドは、クロスプラットフォームの Web Workers APIを実装していますが、いくつかの違いがあります。例:

一方、ワーカー スレッドは実際にはスレッドです。プロセスよりも軽量で、メインスレッドと同じプロセスで実行されます。

他方で

詳細については、ワーカー スレッドに関する Node.js ドキュメントを参照してください。

4.4.2 クラスター

クラスターは Node.js 固有の API です。これにより、ワークロードを分散するために使用できる Node.js プロセスのクラスターを実行できます。プロセスは完全に分離されていますが、サーバーポートを共有しています。それらは、チャネルを介してJSONデータを渡すことで通信できます。

プロセスの分離が不要な場合は、より軽量なワーカー スレッドを使用できます。

4.4.3 子プロセス

子プロセスは、別の Node.js 固有の API です。これにより、(多くの場合、ネイティブシェルを介して)ネイティブコマンドを実行する新しいプロセスを生成できます。この API については、§12「子プロセスでシェルコマンドを実行する」で説明します。

4.5 この章の出典

Node.js イベントループ

イベントループに関するビデオ(この章に必要な背景知識をいくつか復習します)

libuv

JavaScript の並行性

4.5.1 謝辞