使用 Node.js 進行 Shell 指令碼編寫
您可以購買此書的離線版本(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 引擎執行 JavaScript(與 Google 的 Chrome 瀏覽器使用的引擎相同)。

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 * as fs from 'node:fs';

Node 的函數有 3 種不同的樣式。讓我們以內建模組 'node:fs' 為例

我們剛剛看到的 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-based 函式

Promise-based 函式會傳回 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-based 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();
}

事件迴圈會從工作佇列中取出回呼,並在主執行緒中執行它們。如果工作佇列為空,則會區塊(暫停主執行緒)出列。

我們稍後會探討兩個主題

為什麼此迴圈稱為事件迴圈?許多工作會在回應事件時加入,例如作業系統在輸入資料準備好處理時所傳送的事件。

如何將回呼加入工作佇列?以下是常見的可能性

下列程式碼顯示非同步的基於呼叫回呼函式的操作實際運作的狀況。它會從檔案系統中讀取文字檔

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 程式碼的重要規則是:每個工作在其他工作執行之前完成(「執行至完成」)。我們可以在前一個範例中看到:B 行的 'AFTER' 會在 A 行記錄結果之前記錄,因為在呼叫 handleResult() 的工作執行之前,初始工作會先完成。

執行至完成表示工作生命週期不會重疊,而且我們不必擔心共用資料在背景中被變更。這會簡化 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://127.0.0.1:8080 來傳送這些要求。每次我們重新載入該 HTTP 資源時,Node.js 會呼叫從 A 行開始的呼叫回呼函式。它會提供一個訊息,其中包含變數 requestCount 的目前值(B 行),並增加其值(C 行)。

呼叫回呼函式的每個呼叫都是一個新的工作,而變數 requestCount 會在工作之間共用。由於執行至完成,因此很容易讀取和更新。不需要與其他同時執行的工作同步,因為沒有其他工作。

4.2.2 為什麼 Node.js 程式碼會在單一執行緒中執行?

為什麼 Node.js 程式碼預設會在單一執行緒(搭配事件迴圈)中執行?這有兩個好處

由於 Node 的部分非同步作業會在主執行緒以外的執行緒中執行(稍後會詳細說明),並透過工作佇列回報給 JavaScript,因此 Node.js 實際上並非單執行緒。相反地,我們使用單一執行緒來協調在主執行緒中同時非同步執行的作業。

這結束了我們對事件迴圈的第一次探討。如果你覺得淺顯的說明就夠了,可以跳過本節的剩餘部分。繼續閱讀以瞭解更多詳細資訊。

4.2.3 真正的事件迴圈有多個階段

真正的事件迴圈有多個任務佇列,它會從中讀取多個階段 (您可以在 GitHub 儲存庫 nodejs/node 中查看一些 JavaScript 程式碼)。下圖顯示這些階段中最重要的階段

圖中顯示的事件迴圈階段會執行什麼動作?

每個階段會執行直到其佇列為空,或直到處理完最大數量的任務。除了「輪詢」之外,每個階段都會等到下一個輪到它時,才會處理在其執行期間加入的任務。

4.2.3.1 階段「輪詢」

如果此階段花費的時間超過系統依賴的時間限制,它會結束,而下一個階段會執行。

4.2.4 下一個勾選任務和微任務

在每個呼叫的任務之後,會執行一個「子迴圈」,它包含兩個階段

子階段會處理

下一個勾選任務是 Node.js 特有的,微任務是跨平台網路標準(請見 MDN 的支援表格)。

此子迴圈會執行直到兩個佇列都為空。在其執行期間加入的任務會立即處理 - 子迴圈不會等到下一個輪到它時。

4.2.5 比較直接排程任務的不同方式

我們可以使用下列函式和方法將回呼新增至其中一個工作佇列

請務必注意,當透過延遲計時工作時,我們指定的是工作執行最早可能的時間。Node.js 無法總是準時執行工作,因為它只能在工作之間檢查是否有任何計時工作到期。因此,長時間執行的工作可能會導致計時工作延遲。

4.2.5.1 立即執行工作和微工作與一般工作

考慮下列程式碼

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);

我們使用 setImmediate() 來避免 ESM 模組的特殊性:它們在微工作中執行,這表示如果我們在 ESM 模組的最上層排入微工作,它們會在立即執行工作之前執行。正如我們接下來會看到的,這在其他大部分情況下有所不同。

這是前一個程式碼的輸出

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 在階段期間排入立即執行工作和微工作

下列程式碼檢查如果我們在立即執行階段排入立即執行工作,以及在微工作階段排入微工作,會發生什麼事

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() 不會讓 fs.readFile() 挨餓,而 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 是以 C 編寫的函式庫,支援許多平台(Windows、macOS、Linux 等)。Node.js 使用它來處理 I/O 以及更多。

4.3.1 libuv 處理非同步 I/O 的方式

網路 I/O 是非同步的,不會封鎖目前的執行緒。此類 I/O 包括

為了處理非同步 I/O,libuv 使用原生核心 API 並訂閱 I/O 事件(Linux 上的 epoll;BSD Unix 上的 kqueue,包括 macOS;SunOS 上的事件埠;Windows 上的 IOCP)。然後,當事件發生時,它會收到通知。所有這些活動,包括 I/O 本身,都會在主執行緒上發生。

4.3.2 libuv 處理封鎖 I/O 的方式

有些原生 I/O API 是封鎖的(非非同步的)—例如檔案 I/O 和一些 DNS 服務。libuv 從執行緒池(所謂的「工作執行緒池」)中的執行緒呼叫這些 API。這讓主執行緒能夠非同步地使用這些 API。

4.3.3 libuv 的 I/O 以外功能

libuv 協助 Node.js 的不只是 I/O。其他功能包括

順帶一提,libuv 有自己的事件迴圈,您可以在 GitHub 儲存庫 libuv/libuv 中查看其原始碼(函式 uv_run())。

4.4 使用使用者程式碼離開主執行緒

如果我們希望 Node.js 對 I/O 保持回應,我們應避免在主執行緒任務中執行長時間運算。有兩種方法可以做到這一點

以下小節涵蓋了幾個卸載選項。

4.4.1 工作執行緒

工作執行緒實作跨平台 Web Workers API,有一些差異,例如

一方面,工作執行緒真的是執行緒:它們比程序更輕量,並在與主執行緒相同的程序中執行。

另一方面

如需更多資訊,請參閱Node.js 工作執行緒文件

4.4.2 叢集

群集是 Node.js 專屬的 API。它讓我們可以執行 Node.js 程序的群集,用於分配工作負載。這些程序完全隔離,但會共用伺服器埠。它們可以透過通道傳遞 JSON 資料來進行通訊。

如果我們不需要程序隔離,可以使用更輕量的 Worker 執行緒。

4.4.3 子程序

子程序是另一個 Node.js 專屬的 API。它讓我們可以產生執行原生命令的新程序(通常透過原生殼層)。§12「在子程序中執行殼層命令」中介紹了此 API。

4.5 本章來源

Node.js 事件迴圈

關於事件迴圈的影片(複習本章所需的一些背景知識)

libuv

JavaScript 並行

4.5.1 致謝