Node.js 事件循环
什么是事件循环?
事件循环允许 Node.js 执行非阻塞 I/O 操作——尽管默认情况下只使用单个 JavaScript 线程——通过尽可能地将操作卸载到系统内核。
由于大多数现代内核都是多线程的,因此它们可以处理在后台执行的多个操作。当其中一个操作完成时,内核会告诉 Node.js,以便可以将相应的回调添加到轮询队列中,最终执行。我们将在本主题的后面更详细地解释这一点。
事件循环详解
当 Node.js 启动时,它会初始化事件循环,处理提供的输入脚本(或进入 REPL,本文档中未涵盖),这可能会进行异步 API 调用、调度计时器或调用 process.nextTick()
,然后开始处理事件循环。
下图显示了事件循环操作顺序的简化概述。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
TIP
每个方框都将被称为事件循环的“阶段”。
每个阶段都有一个 FIFO 回调队列等待执行。虽然每个阶段都有其特殊之处,但通常情况下,当事件循环进入某个阶段时,它将执行该阶段特有的任何操作,然后执行该阶段队列中的回调,直到队列为空或已执行最大数量的回调。当队列为空或达到回调限制时,事件循环将移动到下一个阶段,依此类推。
由于这些操作中的任何一个都可能调度更多操作,并且在轮询阶段处理的新事件由内核排队,因此在处理轮询事件时,可以排队轮询事件。因此,长时间运行的回调可以使轮询阶段运行的时间比计时器的阈值长得多。有关更多详细信息,请参阅计时器和轮询部分。
TIP
Windows 和 Unix/Linux 实现之间存在细微差异,但这对于本演示并不重要。这里最重要的部分是这里。实际上有七个或八个步骤,但我们关心的步骤——Node.js 实际使用的步骤——是上面的那些步骤。
阶段概述
- timers: 此阶段执行由
setTimeout()
和setInterval()
调度的回调函数。 - pending callbacks: 执行推迟到下一个循环迭代的 I/O 回调函数。
- idle, prepare: 仅用于内部。
- poll: 检索新的 I/O 事件;执行 I/O 相关的回调函数(几乎所有回调函数,除了 close 回调函数、由 timers 调度的回调函数和
setImmediate()
调度的回调函数);在适当的时候,Node.js 将在此阻塞。 - check: 在此调用
setImmediate()
回调函数。 - close callbacks: 一些 close 回调函数,例如
socket.on('close', ...)
.
在事件循环的每次运行之间,Node.js 检查它是否正在等待任何异步 I/O 或计时器,如果没有任何等待则干净地关闭。
详细阶段
timers
计时器指定提供回调函数可能执行的阈值,而不是用户希望其执行的确切时间。计时器回调函数将在指定时间段过去后尽早调度执行;但是,操作系统调度或其他回调函数的运行可能会延迟它们。
TIP
从技术上讲,poll 阶段控制计时器的执行时间。
例如,假设您计划在 100 毫秒阈值后执行超时,然后您的脚本开始异步读取一个文件,该文件需要 95 毫秒:
const fs = require('node:fs')
function someAsyncOperation(callback) {
// 假设这需要 95 毫秒才能完成
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)
// 执行 someAsyncOperation,需要 95 毫秒才能完成
someAsyncOperation(() => {
const startCallback = Date.now()
// 执行一些需要 10 毫秒的操作……
while (Date.now() - startCallback < 10) {
// 不执行任何操作
}
})
当事件循环进入 poll 阶段时,它有一个空队列(fs.readFile()
尚未完成),因此它将等待剩余的毫秒数,直到达到最早计时器的阈值。在等待的 95 毫秒内,fs.readFile()
完成读取文件,其需要 10 毫秒才能完成的回调函数被添加到 poll 队列中并执行。当回调函数完成时,队列中没有更多回调函数,因此事件循环将看到最早计时器的阈值已达到,然后返回到 timers 阶段以执行计时器的回调函数。在此示例中,您将看到计时器被调度与其回调函数被执行之间的总延迟将为 105 毫秒。
TIP
为了防止 poll 阶段使事件循环饿死,libuv(实现 Node.js 事件循环和平台所有异步行为的 C 库)也具有一个硬性最大值(取决于系统),在此之前它将停止轮询更多事件。
待处理回调
此阶段执行某些系统操作(例如 TCP 错误类型)的回调。例如,如果 TCP 套接字在尝试连接时收到 ECONNREFUSED
,某些 *nix 系统希望等待报告错误。这将被排队,以便在 待处理回调 阶段执行。
poll
poll 阶段有两个主要功能:
- 计算应阻塞和轮询 I/O 的时间长度,然后
- 处理 poll 队列中的事件。
当事件循环进入 poll 阶段且没有计划定时器时,将发生以下两种情况之一:
如果 poll 队列 不为空,事件循环将同步迭代其回调队列,直到队列耗尽或达到系统相关的硬性限制。
如果 poll 队列 为空,将发生以下两种情况之一:
如果
setImmediate()
已安排脚本,事件循环将结束 poll 阶段并继续执行检查阶段以执行这些已安排的脚本。如果
setImmediate()
未安排脚本,事件循环将等待回调添加到队列中,然后立即执行它们。
一旦 poll 队列为空,事件循环将检查时间阈值已到的定时器。如果一个或多个定时器已准备就绪,事件循环将返回到 timers 阶段以执行这些定时器的回调。
检查阶段
此阶段允许在轮询阶段完成后立即执行回调。如果轮询阶段变为空闲状态并且已使用setImmediate()
排队脚本,则事件循环可能会继续到检查阶段,而不是等待。
setImmediate()
实际上是一个特殊的计时器,它在事件循环的单独阶段运行。它使用 libuv 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')
})
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
但是,如果将这两个调用移到 I/O 循环中,则始终首先执行立即回调:
// timeout_vs_immediate.js
const fs = require('node:fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
$ node timeout_vs_immediate.js
immediate
timeout
$ node timeout_vs_immediate.js
immediate
timeout
使用setImmediate()
而不是setTimeout()
的主要优势是,如果在 I/O 循环内调度,则setImmediate()
将始终在任何计时器之前执行,而不管存在多少计时器。
process.nextTick()
理解 process.nextTick()
你可能注意到,即使 process.nextTick()
是异步 API 的一部分,在图中也没有显示它。这是因为 process.nextTick()
从技术上讲并不是事件循环的一部分。相反,nextTickQueue
将在当前操作完成后处理,而不管事件循环的当前阶段如何。这里,操作定义为从底层 C/C++ 处理程序的转换,以及处理需要执行的 JavaScript 代码。
回顾我们的图表,在任何给定阶段调用 process.nextTick()
时,所有传递给 process.nextTick()
的回调都将在事件循环继续之前得到解决。这可能会造成一些不好的情况,因为它允许你通过进行递归 process.nextTick()
调用来“饿死”你的 I/O,这阻止了事件循环到达轮询阶段。
为什么允许这样做?
为什么 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 上很大一部分的包。每天都有更多的新模块被添加,这意味着我们等待的时间越长,潜在的破坏就越多。虽然它们令人困惑,但名称本身不会改变。
TIP
我们建议开发者在所有情况下都使用 setImmediate()
,因为它更容易理解。
为什么使用 process.nextTick()
?
主要有两个原因:
允许用户处理错误,清理任何不再需要的资源,或者在事件循环继续之前重试请求。
有时需要允许回调在调用堆栈展开后但在事件循环继续之前运行。
一个例子是满足用户的期望。简单的例子:
const server = net.createServer()
server.on('connection', conn => {})
server.listen(8080)
server.on('listening', () => {})
假设 listen()
在事件循环的开始处运行,但是监听回调放在 setImmediate()
中。除非传递主机名,否则绑定到端口将立即发生。为了使事件循环继续,它必须到达轮询阶段,这意味着存在非零的可能性,即在监听事件之前可能已经接收到连接,从而允许触发连接事件。
另一个例子是扩展 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!')
})