Node.js 事件循环

¥The Node.js Event Loop

什么是事件循环?

¥What is the Event Loop?

事件循环允许 Node.js 执行非阻塞 I/O 操作 - 尽管默认情况下使用单个 JavaScript 线程 - 通过尽可能将操作卸载到系统内核。

¥The event loop is what allows Node.js to perform non-blocking I/O operations — despite the fact that a single JavaScript thread is used by default — by offloading operations to the system kernel whenever possible.

由于大多数现代内核都是多线程的,因此它们可以处理在后台执行的多个操作。当其中一个操作完成时,内核会通知 Node.js,以便将适当的回调添加到轮询队列中,最终执行。我们将在本主题的后面详细解释这一点。

¥Since most modern kernels are multi-threaded, they can handle multiple operations executing in the background. When one of these operations completes, the kernel tells Node.js so that the appropriate callback may be added to the poll queue to eventually be executed. We'll explain this in further detail later in this topic.

事件循环说明

¥Event Loop Explained

当 Node.js 启动时,它会初始化事件循环,处理提供的输入脚本(或放入 REPL,本文档未涵盖),这可能会进行异步 API 调用、安排定时器或调用 process.nextTick(),然后开始处理事件循环。

¥When Node.js starts, it initializes the event loop, processes the provided input script (or drops into the REPL, which is not covered in this document) which may make async API calls, schedule timers, or call process.nextTick(), then begins processing the event loop.

下图显示了事件循环操作顺序的简化概述。

¥The following diagram shows a simplified overview of the event loop's order of operations.

   ┌───────────────────────────┐
┌─>│           timers          
  └─────────────┬─────────────┘
  ┌─────────────┴─────────────┐
       pending callbacks     
  └─────────────┬─────────────┘
  ┌─────────────┴─────────────┐
         idle, prepare       
  └─────────────┬─────────────┘      ┌───────────────┐
  ┌─────────────┴─────────────┐         incoming:   
             poll            │<─────┤  connections, 
  └─────────────┬─────────────┘         data, etc.  
  ┌─────────────┴─────────────┐      └───────────────┘
             check           
  └─────────────┬─────────────┘
  ┌─────────────┴─────────────┐
└──┤      close callbacks      
   └───────────────────────────┘

每个框将被称为事件循环的 "阶段"。

¥Each box will be referred to as a "phase" of the event loop.

每个阶段都有一个要执行的回调的 FIFO 队列。虽然每个阶段都有自己的特殊之处,但通常,当事件循环进入给定阶段时,它将执行特定于该阶段的任何操作,然后在该阶段的队列中执行回调,直到队列耗尽或执行了最大数量的回调。当队列已耗尽或达到回调限制时,事件循环将进入下一阶段,依此类推。

¥Each phase has a FIFO queue of callbacks to execute. While each phase is special in its own way, generally, when the event loop enters a given phase, it will perform any operations specific to that phase, then execute callbacks in that phase's queue until the queue has been exhausted or the maximum number of callbacks has executed. When the queue has been exhausted or the callback limit is reached, the event loop will move to the next phase, and so on.

由于这些操作中的任何一个都可能安排更多操作,并且内核会在轮询阶段处理的新事件排队,因此可以在处理轮询事件时将轮询事件排队。因此,长时间运行的回调可以让轮询阶段运行的时间比定时器的阈值长得多。有关更多详细信息,请参阅 定时器轮询 部分。

¥Since any of these operations may schedule more operations and new events processed in the poll phase are queued by the kernel, poll events can be queued while polling events are being processed. As a result, long running callbacks can allow the poll phase to run much longer than a timer's threshold. See the timers and poll sections for more details.

Windows 和 Unix/Linux 实现之间存在轻微差异,但这对本演示并不重要。最重要的部分在这里。实际上有七八个步骤,但我们关心的是 Node.js 实际使用的步骤 - 就是上面的那些。

¥There is a slight discrepancy between the Windows and the Unix/Linux implementation, but that's not important for this demonstration. The most important parts are here. There are actually seven or eight steps, but the ones we care about — ones that Node.js actually uses - are those above.

阶段概述

¥Phases Overview

  • 定时器:此阶段执行由 setTimeout()setInterval() 安排的回调。

    ¥timers: this phase executes callbacks scheduled by setTimeout() and setInterval().

  • 待处理的回调:执行推迟到下一次循环迭代的 I/O 回调。

    ¥pending callbacks: executes I/O callbacks deferred to the next loop iteration.

  • 空闲,准备:仅在内部使用。

    ¥idle, prepare: only used internally.

  • 轮询:检索新的 I/O 事件;执行 I/O 相关回调(几乎所有回调,除了关闭回调、由定时器调度的回调和 setImmediate());node 将在适当的时候在此处阻塞。

    ¥poll: retrieve new I/O events; execute I/O related callbacks (almost all with the exception of close callbacks, the ones scheduled by timers, and setImmediate()); node will block here when appropriate.

  • 检查:setImmediate() 回调在此处调用。

    ¥check: setImmediate() callbacks are invoked here.

  • 关闭回调:一些关闭回调,例如 socket.on('close', ...)

    ¥close callbacks: some close callbacks, e.g. socket.on('close', ...).

在每次运行事件循环之间,Node.js 都会检查它是否正在等待任何异步 I/O 或定时器,如果没有,则干净地关闭。

¥Between each run of the event loop, Node.js checks if it is waiting for any asynchronous I/O or timers and shuts down cleanly if there are not any.

阶段详情

¥Phases in Detail

定时器

¥timers

定时器指定了执行提供的回调的阈值,而不是用户希望执行的确切时间。定时器回调将在指定的时间过后尽快运行;但是,操作系统调度或其他回调的运行可能会延迟它们。

¥A timer specifies the threshold after which a provided callback may be executed rather than the exact time a person wants it to be executed. Timers callbacks will run as early as they can be scheduled after the specified amount of time has passed; however, Operating System scheduling or the running of other callbacks may delay them.

从技术上讲,轮询阶段 控制定时器的执行时间。

¥Technically, the poll phase controls when timers are executed.

例如,假设你安排在 100 毫秒阈值后执行超时,然后你的脚本开始异步读取需要 95 毫秒的文件:

¥For example, say you schedule a timeout to execute after a 100 ms threshold, then your script starts asynchronously reading a file which takes 95 ms:

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 毫秒才能完成的回调被添加到轮询队列并执行。当回调完成时,队列中不再有回调,因此事件循环将看到已达到最快定时器的阈值,然后返回到定时器阶段以执行定时器的回调。在此示例中,你将看到,定时器的调度和回调的执行之间的总延迟为 105 毫秒。

¥When the event loop enters the poll phase, it has an empty queue (fs.readFile() has not completed), so it will wait for the number of ms remaining until the soonest timer's threshold is reached. While it is waiting 95 ms pass, fs.readFile() finishes reading the file and its callback which takes 10 ms to complete is added to the poll queue and executed. When the callback finishes, there are no more callbacks in the queue, so the event loop will see that the threshold of the soonest timer has been reached then wrap back to the timers phase to execute the timer's callback. In this example, you will see that the total delay between the timer being scheduled and its callback being executed will be 105ms.

为了防止轮询阶段使事件循环挨饿,libuv(实现 Node.js 事件循环和平台所有异步行为的 C 库)在停止轮询更多事件之前也有一个硬最大值(依赖于系统)。

¥To prevent the poll phase from starving the event loop, libuv (the C library that implements the Node.js event loop and all of the asynchronous behaviors of the platform) also has a hard maximum (system dependent) before it stops polling for more events.

待处理回调

¥pending callbacks

此阶段执行某些系统操作的回调,例如 TCP 错误类型。例如,如果 TCP 套接字在尝试连接时收到 ECONNREFUSED,则某些 *nix 系统需要等待以报告错误。这将排队等待在待处理回调阶段执行。

¥This phase executes callbacks for some system operations such as types of TCP errors. For example if a TCP socket receives ECONNREFUSED when attempting to connect, some *nix systems want to wait to report the error. This will be queued to execute in the pending callbacks phase.

轮询

¥poll

轮询阶段有两个主要功能:

¥The poll phase has two main functions:

  1. 计算它应该阻塞和轮询 I/O 多长时间,然后

    ¥Calculating how long it should block and poll for I/O, then

  2. 处理轮询队列中的事件。

    ¥Processing events in the poll queue.

当事件循环进入轮询阶段并且没有安排定时器时,将发生以下两种情况之一:

¥When the event loop enters the poll phase and there are no timers scheduled, one of two things will happen:

  • 如果轮询队列不为空,则事件循环将遍历其回调队列并同步执行它们,直到队列耗尽或达到系统相关的硬限制。

    ¥If the poll queue is not empty, the event loop will iterate through its queue of callbacks executing them synchronously until either the queue has been exhausted, or the system-dependent hard limit is reached.

  • 如果轮询队列为空,则会发生以下两种情况之一:

    ¥If the poll queue is empty, one of two more things will happen:

    • 如果脚本已被 setImmediate() 安排,则事件循环将结束轮询阶段并继续检查阶段以执行这些已安排的脚本。

      ¥If scripts have been scheduled by setImmediate(), the event loop will end the poll phase and continue to the check phase to execute those scheduled scripts.

    • 如果脚本尚未被 setImmediate() 安排,则事件循环将等待回调添加到队列中,然后立即执行它们。

      ¥If scripts have not been scheduled by setImmediate(), the event loop will wait for callbacks to be added to the queue, then execute them immediately.

一旦轮询队列为空,事件循环将检查已达到时间阈值的定时器。如果一个或多个定时器已准备就绪,则事件循环将返回到定时器阶段以执行这些定时器的回调。

¥Once the poll queue is empty the event loop will check for timers whose time thresholds have been reached. If one or more timers are ready, the event loop will wrap back to the timers phase to execute those timers' callbacks.

检查

¥check

此阶段允许一个人在轮询阶段完成后立即执行回调。如果轮询阶段变为空闲状态并且脚本已使用 setImmediate() 排队,则事件循环可能会继续进行检查阶段而不是等待。

¥This phase allows a person to execute callbacks immediately after the poll phase has completed. If the poll phase becomes idle and scripts have been queued with setImmediate(), the event loop may continue to the check phase rather than waiting.

setImmediate() 实际上是一个特殊的定时器,它在事件循环的单独阶段运行。它使用 libuv API,安排回调在轮询阶段完成后执行。

¥setImmediate() is actually a special timer that runs in a separate phase of the event loop. It uses a libuv API that schedules callbacks to execute after the poll phase has completed.

通常,在执行代码时,事件循环最终会进入轮询阶段,等待传入的连接、请求等。但是,如果已使用 setImmediate() 安排回调,并且轮询阶段变为空闲,则回调将结束并继续执行检查阶段,而不是等待轮询事件。

¥Generally, as the code is executed, the event loop will eventually hit the poll phase where it will wait for an incoming connection, request, etc. However, if a callback has been scheduled with setImmediate() and the poll phase becomes idle, it will end and continue to the check phase rather than waiting for poll events.

关闭回调

¥close callbacks

如果套接字或句柄突然关闭(例如 socket.destroy()),则在此阶段将触发 'close' 事件。否则它将通过 process.nextTick() 触发。

¥If a socket or handle is closed abruptly (e.g. socket.destroy()), the 'close' event will be emitted in this phase. Otherwise it will be emitted via process.nextTick().

setImmediate()setTimeout()

¥setImmediate() vs setTimeout()

setImmediate()setTimeout() 相似,但根据调用时间的不同,其行为方式也不同。

¥setImmediate() and setTimeout() are similar, but behave in different ways depending on when they are called.

  • setImmediate() 旨在在当前轮询阶段完成后执行脚本。

    ¥setImmediate() is designed to execute a script once the current poll phase completes.

  • setTimeout() 安排脚本在经过最小阈值(以毫秒为单位)后运行。

    ¥setTimeout() schedules a script to be run after a minimum threshold in ms has elapsed.

定时器的执行顺序将根据调用它们的上下文而有所不同。如果两者都是从主模块内部调用的,那么时间将受到进程性能的约束(这可能会受到机器上运行的其他应用的影响)。

¥The order in which the timers are executed will vary depending on the context in which they are called. If both are called from within the main module, then timing will be bound by the performance of the process (which can be impacted by other applications running on the machine).

例如,如果我们运行以下不在 I/O 周期(即主模块)内的脚本,则两个定时器的执行顺序是不确定的,因为它受进程性能的约束:

¥For example, if we run the following script which is not within an I/O cycle (i.e. the main module), the order in which the two timers are executed is non-deterministic, as it is bound by the performance of the process:

// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});

但是,如果在 I/O 周期内移动两个调用,则始终首先执行立即回调:

¥However, if you move the two calls within an I/O cycle, the immediate callback is always executed first:

// 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() 将始终在任何定时器之前执行,与存在多少个定时器无关。

¥The main advantage to using setImmediate() over setTimeout() is setImmediate() will always be executed before any timers if scheduled within an I/O cycle, independently of how many timers are present.

process.nextTick()

了解 process.nextTick()

¥Understanding process.nextTick()

你可能已经注意到,process.nextTick() 未显示在图表中,即使它是异步 API 的一部分。这是因为 process.nextTick() 在技术上不是事件循环的一部分。相反,nextTickQueue 将在当前操作完成后处理,而不管事件循环的当前阶段如何。以下是操作定义为从底层 C/C++ 处理程序的转换,并处理需要执行的 JavaScript。

¥You may have noticed that process.nextTick() was not displayed in the diagram, even though it's a part of the asynchronous API. This is because process.nextTick() is not technically part of the event loop. Instead, the nextTickQueue will be processed after the current operation is completed, regardless of the current phase of the event loop. Here, an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed.

回顾我们的图表,任何时候在给定阶段调用 process.nextTick(),传递给 process.nextTick() 的所有回调都将在事件循环继续之前得到解决。这可能会造成一些糟糕的情况,因为它允许你通过进行递归 process.nextTick() 调用来 "starve" 你的 I/O,从而阻止事件循环进入轮询阶段。

¥Looking back at our diagram, any time you call process.nextTick() in a given phase, all callbacks passed to process.nextTick() will be resolved before the event loop continues. This can create some bad situations because it allows you to "starve" your I/O by making recursive process.nextTick() calls, which prevents the event loop from reaching the poll phase.

为什么允许这样做?

¥Why would that be allowed?

为什么 Node.js 中会包含这样的东西?部分原因在于设计理念,即 API 应始终保持异步,即使它不必如此。以这个代码片段为例:

¥Why would something like this be included in Node.js? Part of it is a design philosophy where an API should always be asynchronous even where it doesn't have to be. Take this code snippet for example:

function apiCall(arg, callback) {
  if (typeof arg !== 'string')
    return process.nextTick(
      callback,
      new TypeError('argument should be string')
    );
}

该代码片段会进行参数检查,如果不正确,它会将错误传递给回调。API 最近进行了更新,允许将参数传递给 process.nextTick(),从而允许它将回调后传递的任何参数作为回调的参数传播,这样你就不必嵌套函数了。

¥The snippet does an argument check and if it's not correct, it will pass the error to the callback. The API updated fairly recently to allow passing arguments to process.nextTick() allowing it to take any arguments passed after the callback to be propagated as the arguments to the callback so you don't have to nest functions.

我们正在做的是将错误传递回用户,但只有在我们允许用户的其余代码执行之后。通过使用 process.nextTick(),我们保证 apiCall() 始终在用户其余代码之后和允许事件循环继续之前运行其回调。为了实现这一点,允许 JS 调用堆栈展开,然后立即执行提供的回调,这允许一个人对 process.nextTick() 进行递归调用而无需到达 RangeError: Maximum call stack size exceeded from v8

¥What we're doing is passing an error back to the user but only after we have allowed the rest of the user's code to execute. By using process.nextTick() we guarantee that apiCall() always runs its callback after the rest of the user's code and before the event loop is allowed to proceed. To achieve this, the JS call stack is allowed to unwind then immediately execute the provided callback which allows a person to make recursive calls to process.nextTick() without reaching a RangeError: Maximum call stack size exceeded from v8.

这种理念可能会导致一些潜在的问题情况。以这个片段为例:

¥This philosophy can lead to some potentially problematic situations. Take this snippet for example:

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,即使它可能还没有在范围内包含该变量,因为脚本无法运行完成。

¥The user defines someAsyncApiCall() to have an asynchronous signature, but it actually operates synchronously. When it is called, the callback provided to someAsyncApiCall() is called in the same phase of the event loop because someAsyncApiCall() doesn't actually do anything asynchronously. As a result, the callback tries to reference bar even though it may not have that variable in scope yet, because the script has not been able to run to completion.

通过将回调放在 process.nextTick() 中,脚本仍然能够运行完成,允许在调用回调之前初始化所有变量、函数等。它还具有不允许事件循环继续的优点。在允许事件循环继续之前,提醒用户注意错误可能会很有用。以下是使用 process.nextTick() 的先前示例:

¥By placing the callback in a process.nextTick(), the script still has the ability to run to completion, allowing all the variables, functions, etc., to be initialized prior to the callback being called. It also has the advantage of not allowing the event loop to continue. It may be useful for the user to be alerted to an error before the event loop is allowed to continue. Here is the previous example using process.nextTick():

let bar;

function someAsyncApiCall(callback) {
  process.nextTick(callback);
}

someAsyncApiCall(() => {
  console.log('bar', bar); // 1
});

bar = 1;

以下是另一个真实示例:

¥Here's another real world example:

const server = net.createServer(() => {}).listen(8080);

server.on('listening', () => {});

当仅传递端口时,该端口会立即绑定。因此,可以立即调用 'listening' 回调。问题是到那时 .on('listening') 回调尚未设置。

¥When only a port is passed, the port is bound immediately. So, the 'listening' callback could be called immediately. The problem is that the .on('listening') callback will not have been set by that time.

为了解决这个问题,'listening' 事件在 nextTick() 中排队,以允许脚本运行完成。这允许用户设置他们想要的任何事件处理程序。

¥To get around this, the 'listening' event is queued in a nextTick() to allow the script to run to completion. This allows the user to set any event handlers they want.

process.nextTick()setImmediate()

¥process.nextTick() vs setImmediate()

就用户而言,我们有两个类似的调用,但它们的名称令人困惑。

¥We have two calls that are similar as far as users are concerned, but their names are confusing.

  • process.nextTick() 在同一阶段立即触发

    ¥process.nextTick() fires immediately on the same phase

  • setImmediate() 在事件循环的下一次迭代或 'tick' 上触发

    ¥setImmediate() fires on the following iteration or 'tick' of the event loop

本质上,应该交换名称。process.nextTick()setImmediate() 触发得更快,但这是过去的产物,不太可能改变。进行此切换将破坏 npm 上的大部分软件包。每天都有更多新模块被添加,这意味着我们等待的每一天,都可能发生更多的潜在故障。虽然它们令人困惑,但名称本身不会改变。

¥In essence, the names should be swapped. process.nextTick() fires more immediately than setImmediate(), but this is an artifact of the past which is unlikely to change. Making this switch would break a large percentage of the packages on npm. Every day more new modules are being added, which means every day we wait, more potential breakages occur. While they are confusing, the names themselves won't change.

我们建议开发者在所有情况下都使用 setImmediate(),因为它更容易推断。

¥We recommend developers use setImmediate() in all cases because it's easier to reason about.

为什么要使用 process.nextTick()

¥Why use process.nextTick()?

主要有两个原因:

¥There are two main reasons:

  1. 允许用户处理错误、清理任何不需要的资源,或者在事件循环继续之前再次尝试请求。

    ¥Allow users to handle errors, cleanup any then unneeded resources, or perhaps try the request again before the event loop continues.

  2. 有时,有必要允许回调在调用堆栈展开后但在事件循环继续之前运行。

    ¥At times it's necessary to allow a callback to run after the call stack has unwound but before the event loop continues.

一个例子是满足用户的期望。简单示例:

¥One example is to match the user's expectations. Simple example:

const server = net.createServer();
server.on('connection', conn => {});

server.listen(8080);
server.on('listening', () => {});

假设 listen() 在事件循环开始时运行,但监听回调放在 setImmediate() 中。除非传递了主机名,否则将立即绑定到端口。为了使事件循环继续进行,它必须进入轮询阶段,这意味着有非零的机会可以接收到连接,从而允许在监听事件之前触发连接事件。

¥Say that listen() is run at the beginning of the event loop, but the listening callback is placed in a setImmediate(). Unless a hostname is passed, binding to the port will happen immediately. For the event loop to proceed, it must hit the poll phase, which means there is a non-zero chance that a connection could have been received allowing the connection event to be fired before the listening event.

另一个示例是扩展 EventEmitter 并从构造函数中触发事件:

¥Another example is extending an EventEmitter and emitting an event from within the constructor:

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() 设置回调以在构造函数完成后触发事件,从而提供预期的结果:

¥You can't emit an event from the constructor immediately because the script will not have processed to the point where the user assigns a callback to that event. So, within the constructor itself, you can use process.nextTick() to set a callback to emit the event after the constructor has finished, which provides the expected results:

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!');
});