本章概述 Node.js 的工作原理
下图概述了 Node.js 的结构
Node.js 应用程序可用的 API 包括
fetch
和 CompressionStream
属于此类别。process
。'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 “在子进程中运行 shell 命令” 中进行了描述。
'node:fs'
提供文件系统操作,例如读取、写入、复制和删除文件和目录。有关更多信息,请参阅 §8 “在 Node.js 上使用文件系统”。
'node:os'
包含特定于操作系统的常量和实用函数。其中一些在 §7 “在 Node.js 上使用文件系统路径和文件 URL” 中进行了说明。
'node:path'
是一个用于处理文件系统路径的跨平台 API。它在 §7 “在 Node.js 上使用文件系统路径和文件 URL” 中进行了描述。
'node:stream'
包含 Node.js 特定的流 API,这些 API 在 §9 “原生 Node.js 流” 中进行了说明。
'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 * as fs from 'node:fs';
Node 的函数有三种不同的风格。让我们以内置模块 'node:fs'
为例
我们刚刚看到的三个示例演示了具有类似功能的函数的命名约定
fs.readFile()
fsPromises.readFile()
fs.readFileSync()
让我们仔细看看这三种风格是如何工作的。
同步函数最简单——它们立即返回值并将错误作为异常抛出
try {
const result = fs.readFileSync('/etc/passwd', {encoding: 'utf-8'});
console.log(result);
catch (err) {
} console.error(err);
}
基于 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” 中有更详细的解释。
基于回调的函数将结果和错误传递给回调,回调是它们的最后一个参数
.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();
}
事件循环从_任务队列_中取出回调并在主线程中执行它们。如果任务队列为空,则出队_阻塞_(暂停主线程)。
我们稍后将探讨两个主题
为什么这个循环叫做_事件循环_?许多任务是响应事件而添加的,例如,当输入数据准备好被处理时,操作系统发送的事件。
回调如何添加到任务队列中?以下是一些常见的可能性
以下代码显示了正在运行的异步基于回调的操作。它从文件系统中读取文本文件
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 代码的一个重要规则是:每个任务在其他任务运行之前完成(“运行至完成”)。我们可以在前面的示例中看到这一点:B 行中的 'AFTER'
在 A 行中记录结果之前记录,因为初始任务在运行 handleResult()
调用的任务之前完成。
运行至完成意味着任务生命周期不会重叠,我们不必担心共享数据在后台被更改。这简化了 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 请求。我们可以通过使用 Web 浏览器访问 http://localhost:8080
来发送它们。每次我们重新加载该 HTTP 资源时,Node.js 都会调用从 A 行开始的回调。它使用变量 requestCount
的当前值(B 行)提供一条消息并将其递增(C 行)。
回调的每次调用都是一个新任务,变量 requestCount
在任务之间共享。由于运行至完成,因此读取和更新都很容易。无需与其他并发运行的任务同步,因为没有任何任务。
为什么 Node.js 代码默认在单线程(带有一个事件循环)中运行?这有两个好处
正如我们已经看到的,如果只有一个线程,则在任务之间共享数据更简单。
在传统的多线程代码中,需要较长时间才能完成的操作会阻塞当前线程,直到操作完成。此类操作的示例包括读取文件或处理 HTTP 请求。执行许多此类操作的成本很高,因为我们每次都必须创建一个新线程。使用事件循环,每次操作的成本较低,尤其是在每次操作都不需要做太多事情的情况下。这就是为什么基于事件循环的 Web 服务器可以处理比基于线程的 Web 服务器更高的负载。
鉴于 Node 的某些异步操作在主线程以外的线程中运行(稍后会详细介绍),并通过任务队列向 JavaScript 报告,因此 Node.js 并不是真正的单线程。相反,我们使用单线程来协调并发和异步运行的操作(在主线程中)。
以上就是我们对事件循环的初步了解。如果您只需要一个粗略的解释,可以跳过本节的其余部分。继续阅读以了解更多详细信息。
真正的事件循环有多个任务队列,它在多个阶段从这些队列中读取(您可以在 GitHub 存储库 nodejs/node
中查看一些 JavaScript 代码)。下图显示了这些阶段中最重要的一些阶段
图中所示的事件循环阶段分别执行什么操作?
阶段“计时器”调用由以下方法添加到其队列中的定时任务
setTimeout(task, delay=1)
在 delay
毫秒后运行回调 task
。setInterval(task, delay=1)
重复运行回调 task
,暂停时间为 delay
毫秒。阶段“轮询”检索和处理 I/O 事件,并运行其队列中与 I/O 相关的任务。
阶段“检查”(“立即阶段”)执行通过以下方法安排的任务
setImmediate(task)
尽快运行回调 task
(在阶段“轮询”之后“立即”)。每个阶段都会一直运行,直到其队列为空或处理了最大数量的任务。除了“轮询”之外,每个阶段在处理在其运行期间添加的任务之前,都会等待到下一次轮到它时才会处理。
setImmediate()
任务,则处理将前进到“检查”阶段。如果此阶段花费的时间超过系统依赖的时间限制,则该阶段结束,并运行下一阶段。
在每个调用的任务之后,将运行一个由两个阶段组成的“子循环”
子阶段处理
process.nextTick()
排队。queueMicrotask()
、Promise 反应等排队。下一个计时周期任务是 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);
我们使用 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
观察结果
所有下一个计时周期任务都在 enqueueTasks()
之后立即执行。
接下来是所有微任务,包括 Promise 反应。
阶段“计时器”在立即阶段之后。这就是定时任务的执行时间。
我们在立即(“检查”)阶段添加了立即任务(A 行和 B 行)。它们在输出中最后出现,这意味着它们不是在当前阶段执行的,而是在下一个立即阶段执行的。
下一段代码检查了如果我们在下一个计时周期阶段排队一个下一个计时周期任务,并在微任务阶段排队一个微任务会发生什么
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
观察结果
下一个计时周期任务首先执行。
“nextTick 2”在下一个计时周期阶段排队并立即执行。只有当下一个计时周期队列为空时,执行才会继续。
微任务也是如此。
我们在微任务阶段排队“nextTick 3”,执行循环回到下一个计时周期阶段。这些子阶段会重复执行,直到它们的队列都为空。只有这样,执行才会进入下一个全局阶段:首先是“计时器”阶段(“setTimeout 1”)。然后是立即阶段(“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()
不会使在“轮询”阶段报告的 fs.readFile()
饿死(还有一个 Promise 反应,但我们在这里忽略它)。
由于下一个计时周期任务和微任务的调度方式,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 是一个用 C 语言编写的库,支持许多平台(Windows、macOS、Linux 等)。Node.js 使用它来处理 I/O 等。
网络 I/O 是异步的,不会阻塞当前线程。此类 I/O 包括
为了处理异步 I/O,libuv 使用本机内核 API 并订阅 I/O 事件(Linux 上的 epoll;BSD Unix 上的 kqueue,包括 macOS;SunOS 上的事件端口;Windows 上的 IOCP)。然后,它会在事件发生时收到通知。所有这些活动,包括 I/O 本身,都发生在主线程上。
某些本机 I/O API 是阻塞的(不是异步的) - 例如,文件 I/O 和某些 DNS 服务。libuv 从线程池(所谓的“工作线程池”)中的线程调用这些 API。这使得主线程可以异步使用这些 API。
libuv 不仅可以帮助 Node.js 处理 I/O。其他功能包括
顺便说一句,libuv 有自己的事件循环,您可以在 GitHub 存储库 libuv/libuv
中查看其源代码(函数 uv_run()
)。
如果我们想让 Node.js 对 I/O 保持响应,我们应该避免在主线程任务中执行长时间运行的计算。有两种选择可以做到这一点
setImmediate()
运行每个部分。这使得事件循环能够在各部分之间执行 I/O。接下来的几小节将介绍一些卸载选项。
工作线程 实现了 跨平台的 Web Workers API,但有一些区别 - 例如
工作线程必须从模块导入,而 Web Workers 则通过全局变量访问。
在工作线程内部,侦听消息和发布消息是通过浏览器中全局对象的方法完成的。在 Node.js 上,我们改为导入 parentPort
。
我们可以在工作线程中使用大多数 Node.js API。在浏览器中,我们的选择更加有限(我们不能使用 DOM 等)。
在 Node.js 上,可转移的对象(所有类的类扩展了内部类 JSTransferable
的对象)比在浏览器中更多。
一方面,工作线程确实是线程:它们比进程更轻量级,并且与主线程在同一个进程中运行。
另一方面
有关更多信息,请参阅Node.js 文档中关于工作线程的部分。
集群是一个特定于 Node.js 的 API。它允许我们运行 Node.js 进程的*集群*,我们可以使用这些进程来分配工作负载。这些进程是完全隔离的,但共享服务器端口。它们可以通过通道传递 JSON 数据进行通信。
如果我们不需要进程隔离,可以使用更轻量级的 Worker 线程。
子进程是另一个特定于 Node.js 的 API。它允许我们生成运行本地命令(通常通过本地 shell)的新进程。此 API 在§12 “在子进程中运行 shell 命令”中介绍。
Node.js 事件循环
process.nextTick()
”关于事件循环的视频(回顾了本章所需的一些背景知识)
libuv
JavaScript 并发