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      
   └───────────────────────────┘

每个框都将被称为事件循环的一个“阶段”。

每个阶段都有一个先进先出(FIFO)的回调队列需要执行。虽然每个阶段都有其特殊之处,但通常情况下,当事件循环进入某个阶段时,它会执行该阶段特有的任何操作,然后执行该阶段队列中的回调,直到队列耗尽或执行了最大数量的回调。当队列耗尽或达到回调限制时,事件循环将移动到下一个阶段,依此类推。

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

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

阶段概述

  • timers(定时器):此阶段执行由 setTimeout()setInterval() 调度的回调。
  • pending callbacks(待定回调):执行延迟到下一个循环迭代的 I/O 回调。
  • idle, prepare(空闲、准备):仅供内部使用。
  • poll(轮询):检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有回调,除了关闭回调、定时器调度的回调和 setImmediate());在适当的时候,Node 会在此处阻塞。
  • check(检查)setImmediate() 回调在此处被调用。
  • close callbacks(关闭回调):一些关闭回调,例如 socket.on('close', ...)

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

从 libuv 1.45.0 (Node.js 20) 开始,事件循环的行为发生了变化,定时器只在**轮询**阶段之后运行,而不是像早期版本那样在之前和之后都运行。这一变化可能会影响 setImmediate() 回调的执行时机以及它们在某些场景下与定时器的交互方式。

阶段详解

定时器 (timers)

定时器指定了一个**阈值**,*在此之后*提供的回调*可能会被执行*,而不是一个人*希望它被执行的***确切**时间。定时器回调将在指定的时间过去后尽快运行;然而,操作系统调度或其他回调的运行可能会延迟它们。

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

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

const  = ('node:fs');

function () {
  // Assume this takes 95ms to complete
  .('/path/to/file', );
}

const  = .();

(() => {
  const  = .() - ;

  .(`${}ms have passed since I was scheduled`);
}, 100);

// do someAsyncOperation which takes 95 ms to complete
(() => {
  const  = .();

  // do something that will take 10ms...
  while (.() -  < 10) {
    // do nothing
  }
});

当事件循环进入**轮询**阶段时,它有一个空队列(fs.readFile() 尚未完成),所以它会等待直到最近的定时器阈值到达所剩余的毫秒数。在等待 95 毫秒过去后,fs.readFile() 完成文件读取,其需要 10 毫秒完成的回调被添加到**轮询**队列并执行。当该回调完成后,队列中没有更多的回调,所以事件循环将看到最近定时器的阈值已经达到,然后返回到**定时器**阶段以执行该定时器的回调。在这个例子中,你会看到从调度定时器到执行其回调的总延迟将是 105 毫秒。

为了防止**轮询**阶段饿死事件循环,libuv(实现 Node.js 事件循环和平台所有异步行为的 C 库)还有一个硬性的最大值(取决于系统),在此之前它会停止轮询更多事件。

待定回调 (pending callbacks)

此阶段为某些系统操作(例如 TCP 错误类型)执行回调。例如,如果一个 TCP 套接字在尝试连接时收到 ECONNREFUSED,一些 *nix 系统希望等待报告错误。这将被排队在**待定回调**阶段执行。

轮询 (poll)

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

  1. 1. 计算它应该阻塞和轮询 I/O 的时间,然后
  2. 2. 处理**轮询**队列中的事件。

当事件循环进入**轮询**阶段*且没有调度定时器*时,会发生以下两种情况之一:

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

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

    • 如果脚本已由 setImmediate() 调度,事件循环将结束**轮询**阶段并继续到**检查**阶段以执行那些已调度的脚本。

    • 如果脚本**没有**由 setImmediate() 调度,事件循环将等待回调被添加到队列中,然后立即执行它们。

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

检查 (check)

此阶段允许事件循环在**轮询**阶段完成后立即执行回调。如果**轮询**阶段变为空闲,并且有用 setImmediate() 排队的脚本,事件循环可能会继续到**检查**阶段,而不是等待。

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

通常,随着代码的执行,事件循环最终会到达**轮询**阶段,在此它将等待传入的连接、请求等。但是,如果一个回调已用 setImmediate() 调度,并且**轮询**阶段变为空闲,它将结束并继续到**检查**阶段,而不是等待**轮询**事件。

关闭回调 (close callbacks)

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

setImmediate()setTimeout()

setImmediate()setTimeout() 很相似,但根据调用时机的不同,它们的行为方式也不同。

  • setImmediate() 设计为在当前**轮询**阶段完成后执行脚本。
  • setTimeout() 调度一个脚本在至少经过指定的毫秒阈值后运行。

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

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

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

(() => {
  .('immediate');
});

但是,如果你将这两个调用移到一个 I/O 周期内,immediate 回调总是先执行:

// timeout_vs_immediate.js
const  = ('node:fs');

.(, () => {
  (() => {
    .('timeout');
  }, 0);
  (() => {
    .('immediate');
  });
});

使用 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 (, ) {
  if (typeof  !== 'string') {
    return .(
      ,
      new ('argument should be string')
    );
  }
}

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

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

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

let  = null;

// this has an asynchronous signature, but calls callback synchronously
function () {
  ();
}

// the callback is called before `someAsyncApiCall` completes.
(() => {
  // since someAsyncApiCall hasn't completed, bar hasn't been assigned any value
  .('bar', ); // null
});

 = 1;

用户定义 someAsyncApiCall() 具有异步签名,但它实际上是同步操作的。当它被调用时,提供给 someAsyncApiCall() 的回调在事件循环的同一阶段被调用,因为 someAsyncApiCall() 实际上没有做任何异步的事情。结果,回调试图引用 bar,尽管它可能还没有那个变量在作用域内,因为脚本还没有能够运行到完成。

通过将回调放在一个 process.nextTick() 中,脚本仍然能够运行到完成,允许所有的变量、函数等在回调被调用之前被初始化。它还有一个优点,就是不允许事件循环继续。在事件循环被允许继续之前,向用户发出错误警报可能是有用的。这是使用 process.nextTick() 的前一个例子:

let  = null;

function () {
  .();
}

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

 = 1;

这是另一个真实世界的例子:

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

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

当只传递一个端口时,端口会立即绑定。所以,'listening' 回调可能会立即被调用。问题是到那时 .on('listening') 回调还没有被设置。

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

process.nextTick()setImmediate()

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

  • process.nextTick() 在同一阶段立即触发
  • setImmediate() 在事件循环的下一次迭代或“tick”中触发

本质上,这两个名字应该交换。process.nextTick()setImmediate() 更快触发,但这是历史遗留问题,不太可能改变。进行这种转换会破坏 npm 上很大一部分的包。每天都有更多的新模块被添加,这意味着我们等待的每一天,都会发生更多潜在的破坏。虽然它们令人困惑,但名字本身不会改变。

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

为什么使用 process.nextTick()

主要有两个原因:

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

  2. 2. 有时需要在调用栈展开后但在事件循环继续前允许一个回调运行。

一个例子是匹配用户的期望。简单例子:

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

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

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

另一个例子是扩展一个 EventEmitter 并在构造函数内部发出一个事件:

const  = ('node:events');

class  extends  {
  constructor() {
    super();
    this.('event');
  }
}

const  = new ();
.('event', () => {
  .('an event occurred!');
});

你不能立即从构造函数中发出事件,因为脚本还没有处理到用户为该事件分配回调的地步。所以,在构造函数本身内部,你可以使用 process.nextTick() 来设置一个回调,在构造函数完成后发出事件,这提供了预期的结果:

const  = ('node:events');

class  extends  {
  constructor() {
    super();

    // use nextTick to emit the event once a handler is assigned
    .(() => {
      this.('event');
    });
  }
}

const  = new ();
.('event', () => {
  .('an event occurred!');
});