Node.js 事件循环
什么是事件循环?
事件循环使 Node.js 能够执行非阻塞 I/O 操作——尽管默认情况下使用单个 JavaScript 线程——通过尽可能将操作卸载到系统内核。
由于大多数现代内核都是多线程的,它们可以处理在后台执行的多个操作。当其中一个操作完成时,内核会告诉 Node.js,以便可以将适当的回调添加到 poll 队列中,以便最终执行。 我们将在本主题后面的内容中对此进行详细解释。
事件循环详解
当 Node.js 启动时,它会初始化事件循环,处理提供的输入脚本(或进入 REPL,本文档未涵盖),该脚本可以进行异步 API 调用、调度定时器或调用 process.nextTick()
,然后开始处理事件循环。
下图显示了事件循环操作顺序的简化概述。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
每个框将被称为事件循环的“阶段”。
每个阶段都有一个 FIFO 回调队列来执行。 虽然每个阶段都有其自身的特殊性,但通常,当事件循环进入给定阶段时,它将执行特定于该阶段的任何操作,然后执行该阶段队列中的回调,直到队列耗尽或执行了最大数量的回调。 当队列耗尽或达到回调限制时,事件循环将移动到下一个阶段,依此类推。
由于这些操作中的任何一个都可能调度更多操作,并且内核会对 poll 阶段处理的新事件进行排队,因此在处理轮询事件时可以对轮询事件进行排队。 因此,长时间运行的回调可以使轮询阶段的运行时间比计时器的阈值长得多。 有关更多详细信息,请参见 timers 和 poll 部分。
Windows 和 Unix/Linux 实现之间存在细微差异,但对于此演示而言并不重要。 最重要的部分都在这里。 实际上有七个或八个步骤,但是我们关心的步骤(Node.js 实际使用的步骤)是上面的步骤。
阶段概述
- 计时器 (timers):此阶段执行由
setTimeout()
和setInterval()
调度的回调。 - 待定回调 (pending callbacks):执行推迟到下一个循环迭代的 I/O 回调。
- 空闲,准备 (idle, prepare):仅在内部使用。
- 轮询 (poll):检索新的 I/O 事件; 执行 I/O 相关的回调(几乎所有回调,除了关闭回调,由计时器调度的回调以及
setImmediate()
); 在适当的时候,节点将在此处阻塞。 - 检查 (check):在此处调用
setImmediate()
回调。 - 关闭回调 (close callbacks):一些关闭回调,例如
socket.on('close', ...)
。
在每次运行事件循环之间,Node.js 会检查它是否正在等待任何异步 I/O 或计时器,如果没有,则会干净地关闭。
阶段详解
计时器
计时器指定了提供的回调可能被执行的阈值之后,而不是某人希望其被执行的确切时间。 计时器回调会在指定的时长过去后尽快运行; 但是,操作系统调度或运行其他回调可能会延迟它们。
从技术上讲,轮询阶段控制何时执行计时器。
例如,假设您安排一个超时在 100 毫秒阈值后执行,然后您的脚本开始异步读取一个需要 95 毫秒的文件
const fs = require('node:fs');
function someAsyncOperation(callback) {
// Assume this takes 95ms to complete
fs.readFile('/path/to/file', callback);
}
const timeoutScheduled = Date.now();
setTimeout(() => {
const delay = Date.now() - timeoutScheduled;
console.log(`${delay}ms have passed since I was scheduled`);
}, 100);
// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
const startCallback = Date.now();
// do something that will take 10ms...
while (Date.now() - startCallback < 10) {
// do nothing
}
});
当事件循环进入 轮询 阶段时,它有一个空队列(fs.readFile()
尚未完成),因此它将等待到达到最早计时器的阈值的剩余毫秒数。 在等待 95 毫秒的过程中,fs.readFile()
完成读取文件,并且需要 10 毫秒才能完成的回调被添加到 poll 队列并执行。 当回调完成后,队列中没有其他回调,因此事件循环将看到已达到最早计时器的阈值,然后返回到 计时器 阶段以执行计时器的回调。 在此示例中,您将看到计时器调度和执行其回调之间的总延迟为 105 毫秒。
为了防止 轮询 阶段使事件循环匮乏,libuv(实现 Node.js 事件循环和平台所有异步行为的 C 库)也有一个硬性最大值(系统相关),然后它停止轮询更多事件。
待定回调
此阶段执行某些系统操作的回调,例如 TCP 错误的类型。 例如,如果 TCP 套接字在尝试连接时收到 ECONNREFUSED
,则某些 *nix 系统希望等待报告错误。 这将被排队以在 待定回调 阶段执行。
轮询
轮询 阶段有两个主要功能
- 计算它应该阻塞和轮询 I/O 多长时间,然后
- 处理 轮询 队列中的事件。
当事件循环进入 轮询 阶段并且没有安排计时器时,将发生以下两种情况之一
-
如果 轮询 队列 不为空 ,事件循环将遍历其回调队列,同步执行它们,直到队列耗尽或达到系统相关的硬限制。
-
如果 轮询 队列 为空 ,将发生以下两种情况之一
-
如果脚本已由
setImmediate()
调度,则事件循环将结束 轮询 阶段并继续到 检查 阶段以执行那些调度的脚本。 -
如果脚本 没有 由
setImmediate()
调度,则事件循环将等待回调被添加到队列中,然后立即执行它们。
-
一旦 轮询 队列为空,事件循环将检查时间阈值已达到的计时器。 如果一个或多个计时器已准备就绪,则事件循环将返回到 计时器 阶段以执行这些计时器的回调。
检查
此阶段允许事件循环在 轮询 阶段完成后立即执行回调。 如果 轮询 阶段变为空闲状态并且脚本已使用 setImmediate()
排队,则事件循环可能会继续到 检查 阶段,而不是等待。
setImmediate()
实际上是一个特殊的计时器,它在事件循环的单独阶段运行。 它使用 libuv API,该 API 调度回调以在 轮询 阶段完成后执行。
通常,当代码执行时,事件循环最终将进入 轮询 阶段,在该阶段它将等待传入的连接、请求等。但是,如果已使用 setImmediate()
调度回调,并且 轮询 阶段变为空闲状态,它将结束并继续到 检查 阶段,而不是等待 轮询 事件。
关闭回调
如果套接字或句柄突然关闭(例如 socket.destroy()
),则 'close'
事件将在此阶段发出。 否则,它将通过 process.nextTick()
发出。
setImmediate()
与 setTimeout()
setImmediate()
和 setTimeout()
相似,但它们的行为方式不同,具体取决于它们的调用时间。
setImmediate()
旨在在当前 轮询 阶段完成后执行脚本。setTimeout()
计划在经过最小阈值(以毫秒为单位)后运行脚本。
计时器的执行顺序将根据调用它们的上下文而有所不同。 如果两者都是从主模块中调用的,则计时将受到进程性能的约束(这可能会受到在计算机上运行的其他应用程序的影响)。
例如,如果我们运行以下不在 I/O 循环内的脚本(即主模块),则两个定时器执行的顺序是不确定的,因为它受到进程性能的限制。
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
但是,如果将这两个调用移动到 I/O 循环中,则 immediate 回调总是先执行。
// timeout_vs_immediate.js
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
相对于 setTimeout()
,使用 setImmediate()
的主要优点是,如果在 I/O 循环内安排,setImmediate()
总是会在任何定时器之前执行,而与定时器的数量无关。
process.nextTick()
理解 process.nextTick()
您可能已经注意到,process.nextTick()
未显示在图中,即使它是异步 API 的一部分。 这是因为 process.nextTick()
从技术上讲不是事件循环的一部分。 相反,无论事件循环的当前阶段如何,nextTickQueue
将在当前操作完成后处理。 在这里,操作定义为从底层 C/C++ 处理程序转换,以及处理需要执行的 JavaScript。
回顾我们的图表,任何时候你在给定的阶段调用 process.nextTick()
,所有传递给 process.nextTick()
的回调函数都会在事件循环继续之前被解析。 这可能会导致一些不好的情况,因为**它允许你通过进行递归 process.nextTick()
调用来“饿死”你的 I/O**,这会阻止事件循环到达 **poll** 阶段。
为什么允许这样做?
为什么 Node.js 中会包含这样的东西? 部分原因是设计理念,即 API 应该始终是异步的,即使它不必是。 以以下代码片段为例:
function apiCall(arg, callback) {
if (typeof arg !== 'string')
return process.nextTick(
callback,
new TypeError('argument should be string')
);
}
该片段会进行参数检查,如果参数不正确,则会将错误传递给回调函数。 API 最近进行了更新,允许将参数传递给 process.nextTick()
,从而允许它采用回调函数之后传递的任何参数作为回调函数的参数传递,这样您就不必嵌套函数。
我们正在做的是将错误返回给用户,但仅在 之后 我们允许用户代码的其余部分执行。 通过使用 process.nextTick()
,我们保证 apiCall()
始终在用户代码的其余部分 之后 以及事件循环被允许继续 之前 运行其回调函数。 为了实现这一点,JS 调用堆栈被允许展开,然后立即执行提供的回调函数,这允许人们递归调用 process.nextTick()
而不会达到 RangeError: Maximum call stack size exceeded from v8
。
这种理念可能会导致一些潜在的问题情况。 以以下代码片段为例:
let bar;
// this has an asynchronous signature, but calls callback synchronously
function someAsyncApiCall(callback) {
callback();
}
// the callback is called before `someAsyncApiCall` completes.
someAsyncApiCall(() => {
// since someAsyncApiCall hasn't completed, bar hasn't been assigned any value
console.log('bar', bar); // undefined
});
bar = 1;
用户定义 someAsyncApiCall()
具有异步签名,但它实际上是同步操作的。 当调用它时,提供给 someAsyncApiCall()
的回调函数在事件循环的同一阶段中被调用,因为 someAsyncApiCall()
实际上没有做任何异步的事情。 因此,回调尝试引用 bar
,即使它可能尚未在该变量的范围内,因为脚本尚未能够运行完成。
通过将回调函数放在 process.nextTick()
中,脚本仍然有能力运行完成,允许在调用回调函数之前初始化所有变量、函数等。 它还具有不允许事件循环继续的优点。 在允许事件循环继续之前,提醒用户注意错误可能很有用。 这是使用 process.nextTick()
的先前示例:
let bar;
function someAsyncApiCall(callback) {
process.nextTick(callback);
}
someAsyncApiCall(() => {
console.log('bar', bar); // 1
});
bar = 1;
这是另一个真实世界的例子:
const server = net.createServer(() => {}).listen(8080);
server.on('listening', () => {});
当仅传递端口时,端口会立即绑定。 因此,可能会立即调用 'listening'
回调函数。 问题在于,到那时 .on('listening')
回调函数尚未设置。
为了解决这个问题,'listening'
事件被排队到 nextTick()
中,以允许脚本运行完成。 这允许用户设置他们想要的任何事件处理程序。
process.nextTick()
vs setImmediate()
我们有两个调用,就用户而言,它们是相似的,但它们的名称令人困惑。
process.nextTick()
在同一阶段立即触发setImmediate()
在事件循环的下一个迭代或“tick”中触发
本质上,名称应该互换。 process.nextTick()
比 setImmediate()
更快触发,但这是过去的遗留问题,不太可能改变。 进行此切换会破坏 npm 上很大一部分软件包。 每天都在添加更多新的模块,这意味着我们等待的每一天都会发生更多潜在的破坏。 虽然它们令人困惑,但名称本身不会改变。
我们建议开发人员在所有情况下都使用
setImmediate()
,因为它更容易推理。
为什么要使用 process.nextTick()
?
有两个主要原因:
-
允许用户处理错误,清理任何不需要的资源,或者在事件循环继续之前再次尝试请求。
-
有时需要允许回调函数在调用堆栈展开之后但在事件循环继续之前运行。
一个例子是匹配用户的期望。 简单示例
const server = net.createServer();
server.on('connection', conn => {});
server.listen(8080);
server.on('listening', () => {});
假设 listen()
在事件循环的开始处运行,但 listening 回调函数放在 setImmediate()
中。 除非传递了主机名,否则与端口的绑定将立即发生。 为了使事件循环继续,它必须到达 poll 阶段,这意味着很有可能在 listening 事件之前收到连接并触发连接事件。
另一个例子是扩展 EventEmitter
并从构造函数中发出事件
const EventEmitter = require('node:events');
class MyEmitter extends EventEmitter {
constructor() {
super();
this.emit('event');
}
}
const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
console.log('an event occurred!');
});
您不能立即从构造函数中发出事件,因为脚本不会处理到用户将回调函数分配给该事件的点。 因此,在构造函数本身中,您可以使用 process.nextTick()
设置一个回调函数,以在构造函数完成后发出该事件,从而提供预期的结果。
const EventEmitter = require('node:events');
class MyEmitter extends EventEmitter {
constructor() {
super();
// use nextTick to emit the event once a handler is assigned
process.nextTick(() => {
this.emit('event');
});
}
}
const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
console.log('an event occurred!');
});