この章では、Node.jsの動作の概要を説明します。
次の図は、Node.jsの構成の概要を示しています。
Node.jsアプリで利用できるAPIは、以下で構成されています。
fetch
やCompressionStream
などのクロスプラットフォームのWeb APIはこのカテゴリに分類されます。process
など、Node.jsのみのAPIもグローバルです。'node:path'
(ファイルシステムパスを処理するための関数と定数)および'node:fs'
(ファイルシステムに関連する機能))を通じて提供されます。Node.js APIは、部分的にJavaScriptで、部分的にC++で実装されています。後者は、オペレーティングシステムとインターフェースするために必要です。
Node.jsは、埋め込みのV8 JavaScriptエンジン(Google Chromeブラウザで使用されているものと同じエンジン)を介してJavaScriptを実行します。
以下は、Nodeのグローバル変数のいくつかのハイライトです。
crypto
は、Web互換のcrypto APIへのアクセスを提供します。
console
は、ブラウザ内の同じグローバル変数(console.log()
など)と多くの重複があります。
fetch()
は、FetchブラウザAPIを使用できるようにします。
process
には、クラスProcess
のインスタンスが含まれており、コマンドライン引数、標準入力、標準出力などにアクセスできます。
structuredClone()
は、オブジェクトをクローンするためのブラウザ互換の関数です。
URL
は、URLを処理するためのブラウザ互換のクラスです。
この章全体で、さらに多くのグローバル変数が紹介されています。
次の組み込みモジュールは、グローバル変数の代替を提供します。
'node:console'
は、グローバル変数console
の代替です。
console.log('Hello!');
import {log} from 'node:console';
log('Hello!');
'node:process'
は、グローバル変数process
の代替です。
console.log(process.argv);
import {argv} from 'node:process';
console.log(process.argv);
原則として、モジュールを使用する方がグローバル変数を使用するよりもクリーンです。ただし、グローバル変数console
とprocess
の使用は確立されたパターンであるため、それらから逸脱することにも欠点があります。
NodeのAPIのほとんどは、モジュールを通じて提供されます。以下は、頻繁に使用されるいくつかのモジュールです(アルファベット順)。
'node:assert/strict'
:アサーションは、条件が満たされているかどうかを確認し、満たされていない場合はエラーを報告する関数です。アプリケーションコードやユニットテストで使用できます。以下はこのAPIの使用例です。
import * as assert from 'node:assert/strict';
.equal(3 + 4, 7);
assert.equal('abc'.toUpperCase(), 'ABC');
assert
.deepEqual({prop: true}, {prop: true}); // deep comparison
assert.notEqual({prop: true}, {prop: true}); // shallow comparison assert
'node:child_process'
は、ネイティブコマンドを同期的にまたは別のプロセスで実行するためのものです。このモジュールについては、§12「子プロセスでシェルコマンドを実行する」で説明します。
'node:fs'
は、ファイルの読み取り、書き込み、コピー、削除、およびディレクトリなどのファイルシステム操作を提供します。詳細については、§8「Node.jsでのファイルシステムの操作」を参照してください。
'node:os'
には、オペレーティングシステム固有の定数とユーティリティ関数が含まれています。その一部は、§7「Node.jsでのファイルシステムパスとファイルURLの操作」で説明されています。
'node:path'
は、ファイルシステムパスを操作するためのクロスプラットフォームAPIです。これについては、§7「Node.jsでのファイルシステムパスとファイルURLの操作」で説明します。
'node:stream'
には、§9「ネイティブNode.jsストリーム」で説明されているNode.js固有のストリームAPIが含まれています。
'node:util'
には、さまざまなユーティリティ関数が含まれています。
モジュール'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('_'));
.sort();
modules.deepEqual(
assert.slice(0, 5),
modules
['assert',
'assert/strict',
'async_hooks',
'buffer',
'child_process',
]; )
このセクションでは、次のimportを使用します。
import * as fs from 'node:fs';
Nodeの関数には、3つの異なるスタイルがあります。例として組み込みモジュール'node:fs'
を見てみましょう。
先ほど見た3つの例は、同様の機能を持つ関数の命名規則を示しています。
fs.readFile()
fsPromises.readFile()
fs.readFileSync()
これらの3つのスタイルがどのように機能するかを詳しく見てみましょう。
同期関数は最も単純で、値をすぐに返し、エラーを例外としてスローします。
try {
const result = fs.readFileSync('/etc/passwd', {encoding: 'utf-8'});
console.log(result);
catch (err) {
} console.error(err);
}
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」で詳しく説明されています。
コールバックベースの関数は、結果とエラーを最後のパラメータであるコールバックに渡します。
.readFile('/etc/passwd', {encoding: 'utf-8'},
fs, result) => {
(errif (err) {
console.error(err);
return;
}console.log(result);
}; )
このスタイルについては、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)
}.readFile('reminder.txt', 'utf-8',
fs
handleResult;
)console.log('AFTER'); // (B)
これは出力結果です。
AFTER
Don’t forget!
fs.readFile()
は、別のスレッドでファイルを読み取るコードを実行します。この場合、コードは成功し、このコールバックをタスクキューに追加します。
=> handleResult(null, 'Don’t forget!') ()
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(
, res) => { // (A)
(_req.writeHead(200);
res.end('This is request number ' + requestCount); // (B)
res++; // (C)
requestCount
};
).listen(8080); server
このコードは、node server.mjs
で実行します。その後、コードが起動し、HTTPリクエストを待ちます。ウェブブラウザを使用してhttps://:8080
にアクセスすることで、リクエストを送信できます。そのHTTPリソースをリロードするたびに、Node.jsはA行で開始されるコールバックを呼び出します。コールバックは、変数requestCount
の現在の値(B行)を含むメッセージを処理し、それをインクリメントします(C行)。
コールバックの各呼び出しは新しいタスクであり、変数requestCount
はタスク間で共有されます。完了まで実行されるため、読み取りと更新が簡単です。同時に実行されている他のタスクと同期する必要はありません。なぜなら、同時に実行されているタスクがないからです。
なぜNode.jsのコードは(イベントループを使用して)デフォルトでシングルスレッドで実行されるのでしょうか?それには2つの利点があります。
すでに見たように、シングルスレッドしかない場合、タスク間でデータを共有するのがより簡単になります。
従来のマルチスレッドコードでは、完了に時間がかかる操作は、操作が完了するまで現在のスレッドをブロックします。そのような操作の例としては、ファイルの読み取りやHTTPリクエストの処理があります。これらの操作を多数実行すると、毎回新しいスレッドを作成する必要があるため、コストがかかります。イベントループを使用すると、特に各操作があまり多くのことを行わない場合、操作ごとのコストが低くなります。そのため、イベントループベースのWebサーバーは、スレッドベースのWebサーバーよりも高い負荷を処理できます。
Nodeの一部の非同期操作がメインスレッド以外のスレッドで実行され(これについては後述します)、タスクキューを介してJavaScriptに報告されることを考えると、Node.jsは実際にはシングルスレッドではありません。代わりに、同時かつ非同期に実行される操作(メインスレッド内)を調整するためにシングルスレッドを使用します。
これで、イベントループの最初の概要は終わりです。もし表面的な説明で十分な場合は、このセクションの残りの部分をスキップしてください。さらに詳しく知りたい場合は読み進めてください。
実際のイベントループには、複数のフェーズで読み込む複数のタスクキューがあります(GitHubリポジトリnodejs/node
にあるJavaScriptコードの一部を確認できます)。次の図は、これらのフェーズのうち最も重要なものを示しています。
図に示されているイベントループのフェーズは何をするのでしょうか?
フェーズ「timers」は、次のいずれかによってキューに追加された時間指定タスクを呼び出します。
setTimeout(task, delay=1)
は、delay
ミリ秒後にコールバックtask
を実行します。setInterval(task, delay=1)
は、delay
ミリ秒の間隔を置いて、コールバックtask
を繰り返し実行します。フェーズ「poll」は、I/Oイベントを取得して処理し、キューからI/O関連タスクを実行します。
フェーズ「check」(「immediateフェーズ」)は、次を使用してスケジュールされたタスクを実行します。
setImmediate(task)
は、コールバックtask
を可能な限り早く(「poll」フェーズの「直後」)実行します。各フェーズは、キューが空になるか、最大タスク数が処理されるまで実行されます。「poll」を除き、各フェーズは、実行中に追加されたタスクを処理する前に、次のターンまで待ちます。
setImmediate()
タスクがある場合、処理は「check」フェーズに進みます。このフェーズがシステム依存の時間制限よりも長くかかると、終了し、次のフェーズが実行されます。
呼び出された各タスクの後、「サブループ」が実行されます。これは2つのフェーズで構成されます。
サブフェーズは次のものを処理します。
process.nextTick()
でキューに登録されたnext-tickタスク。queueMicrotask()
、Promiseリアクションなどでキューに登録されたマイクロタスク。next-tickタスクはNode.js固有であり、マイクロタスクはクロスプラットフォームのWeb標準です(MDNのサポートテーブルを参照)。
このサブループは、両方のキューが空になるまで実行されます。実行中に追加されたタスクはすぐに処理されます。サブループは次のターンまで待機しません。
次の関数とメソッドを使用して、いずれかのタスクキューにコールバックを追加できます。
setTimeout()
(Web標準)setInterval()
(Web標準)setImmediate()
(Node.js固有)process.nextTick()
(Node.js固有)queueMicrotask()
:(Web標準)遅延を介してタスクのタイミングを設定する場合、タスクが実行される最も早い時間を指定していることに注意することが重要です。Node.jsは、タスク間に時間指定タスクの期限が来ているかどうかを確認できるだけであり、常にスケジュールされた時間に正確に実行できるわけではありません。したがって、実行時間の長いタスクにより、時間指定タスクが遅れる可能性があります。
次のコードについて考えます。
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
考察
すべてのnext-tickタスクは、enqueueTasks()
の直後に実行されます。
それらに続いて、Promiseリアクションを含むすべてのマイクロタスクが実行されます。
フェーズ「timers」は、immediateフェーズの後にきます。時間指定タスクが実行されるときです。
immediate(「check」)フェーズ中にimmediateタスクを追加しました(A行とB行)。それらは出力で最後に表示されます。つまり、現在のフェーズ中ではなく、次のimmediateフェーズ中に実行されました。
次のコードでは、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
考察
next-tickタスクが最初に実行されます。
「nextTick 2」は、next-tickフェーズ中にキューに入れられ、すぐに実行されます。実行は、next-tickキューが空になった後にのみ続行されます。
マイクロタスクについても同じことが当てはまります。
マイクロタスクフェーズ中に「nextTick 3」をキューに入れ、実行はnext-tickフェーズに戻ります。これらのサブフェーズは、両方のキューが空になるまで繰り返されます。その後でのみ、実行は次のグローバルフェーズに進みます。最初に「timers」フェーズ(「setTimeout 1」)。次に、immediateフェーズ(「setImmediate 1」)。
次のコードでは、どの種類のタスクがイベントループフェーズを枯渇させる(無限再帰によって実行を防ぐ)ことができるかを探ります。
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()
の両方が最終行の出力を防ぎます。
イベントループの各反復の終わりに、Node.jsは終了する時間かどうかを確認します。保留中のタイムアウト(時間指定タスクの場合)の参照カウントを保持します。
setImmediate()
、setInterval()
、またはsetTimeout()
を使用して時間指定タスクをスケジュールすると、参照カウントが増加します。イベントループの反復の終わりに参照カウントがゼロの場合、Node.jsは終了します。
次の例でそれを見ることができます。
function timeout(ms) {
return new Promise(
, _reject) => {
(resolvesetTimeout(resolve, ms); // (A)
};
)
}await timeout(3_000);
Node.jsは、timeout()
によって返されたPromiseが履行されるまで待機します。なぜでしょうか?A行でスケジュールしたタスクがイベントループを維持しているためです。
対照的に、Promiseを作成しても参照カウントは増加しません。
function foreverPending() {
return new Promise(
, _reject) => {}
(_resolve;
)
}await foreverPending(); // (A)
この場合、A行のawait
中に実行は一時的にこの(メイン)タスクから離れます。イベントループの終わりに、参照カウントはゼロになり、Node.jsは終了します。ただし、終了は成功しません。つまり、終了コードは0ではなく、13(「未完了のトップレベルAwait」)です。
タイムアウトがイベントループを維持するかどうかを手動で制御できます。デフォルトでは、setImmediate()
、setInterval()
、およびsetTimeout()
を介してスケジュールされたタスクは、保留されている限り、イベントループを維持します。これらの関数は、クラスTimeout
のインスタンスを返し、そのメソッド.unref()
は、タイムアウトがアクティブであってもNode.jsが終了するのを妨げないように、そのデフォルトを変更します。メソッド.ref()
はデフォルトを復元します。
Tim Perryは、.unref()
の使用例を述べています。彼のライブラリは、バックグラウンドタスクを繰り返し実行するためにsetInterval()
を使用しました。そのタスクはアプリケーションが終了するのを妨げました。彼は.unref()
を使用して問題を修正しました。
libuvは、多くのプラットフォーム(Windows、macOS、Linuxなど)をサポートするCで記述されたライブラリです。Node.jsは、I/Oなどを処理するためにこれを使用します。
ネットワークI/Oは非同期であり、現在のスレッドをブロックしません。このようなI/Oには、次のようなものがあります。
非同期I/Oを処理するために、libuvはネイティブカーネルAPIを使用し、I/Oイベントをサブスクライブします(Linuxではepoll、macOSを含むBSD Unixではkqueue、SunOSではイベントポート、WindowsではIOCP)。その後、それらが発生すると通知を受け取ります。I/O自体を含むこれらすべてのアクティビティは、メインスレッドで発生します。
一部のネイティブI/O APIはブロッキング(非同期ではない)です。たとえば、ファイルI/Oや一部のDNSサービスです。libuvは、スレッドプールのスレッド(いわゆる「ワーカプール」)からこれらのAPIを呼び出します。これにより、メインスレッドはこれらのAPIを非同期的に使用できるようになります。
libuvは、I/Oだけでなく、Node.jsをさらに支援します。その他の機能には、次のものがあります。
ちなみに、libuvには独自のイベントループがあり、そのソースコードはGitHubリポジトリlibuv/libuv
(関数uv_run()
)で確認できます。
Node.jsがI/Oに応答できるようにするには、メインスレッドタスクで実行時間の長い計算を実行しないようにする必要があります。これを行うには2つのオプションがあります。
setImmediate()
を介して各断片を実行できます。これにより、イベントループは断片間でI/Oを実行できるようになります。次のサブセクションでは、オフロードのいくつかのオプションについて説明します。
ワーカー スレッドは、クロスプラットフォームの Web Workers APIを実装していますが、いくつかの違いがあります。例:
ワーカー スレッドはモジュールからインポートする必要があり、Web Workers はグローバル変数経由でアクセスします。
ワーカー内では、メッセージのリスニングとメッセージの投稿は、ブラウザのグローバルオブジェクトのメソッドを介して行われます。Node.js では、代わりに parentPort
をインポートします。
ワーカーからは、ほとんどの Node.js API を使用できます。ブラウザでは、選択肢がより限られています(DOM などは使用できません)。
Node.js では、ブラウザよりも多くのオブジェクトが転送可能(クラスが内部クラス JSTransferable
を拡張するすべてのオブジェクト)です。
一方、ワーカー スレッドは実際にはスレッドです。プロセスよりも軽量で、メインスレッドと同じプロセスで実行されます。
他方で
Atomics
は、SharedArrayBuffers を使用する際に役立つアトミック操作と同期プリミティブを提供します。詳細については、ワーカー スレッドに関する Node.js ドキュメントを参照してください。
クラスターは Node.js 固有の API です。これにより、ワークロードを分散するために使用できる Node.js プロセスのクラスターを実行できます。プロセスは完全に分離されていますが、サーバーポートを共有しています。それらは、チャネルを介してJSONデータを渡すことで通信できます。
プロセスの分離が不要な場合は、より軽量なワーカー スレッドを使用できます。
子プロセスは、別の Node.js 固有の API です。これにより、(多くの場合、ネイティブシェルを介して)ネイティブコマンドを実行する新しいプロセスを生成できます。この API については、§12「子プロセスでシェルコマンドを実行する」で説明します。
Node.js イベントループ
process.nextTick()
」イベントループに関するビデオ(この章に必要な背景知識をいくつか復習します)
libuv
JavaScript の並行性