使用 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 引擎(与 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 * as fs from 'node:fs';

Node 的函数有三种不同的风格。让我们以内置模块 'node:fs' 为例

我们刚刚看到的三个示例演示了具有类似功能的函数的命名约定

让我们仔细看看这三种风格是如何工作的。

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,这些 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” 中有更详细的解释。

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 请求。我们可以通过使用 Web 浏览器访问 http://localhost: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 特有的,微任务是一个跨平台的 Web 标准(请参阅 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() 饿死(还有一个 Promise 反应,但我们在这里忽略它)。

由于下一个计时周期任务和微任务的调度方式,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。它允许我们生成运行本地命令(通常通过本地 shell)的新进程。此 API 在§12 “在子进程中运行 shell 命令”中介绍。

4.5 本章资料来源

Node.js 事件循环

关于事件循环的视频(回顾了本章所需的一些背景知识)

libuv

JavaScript 并发

4.5.1 致谢