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 队列,用于执行回调函数。虽然每个阶段都有其特殊之处,但一般来说,当事件循环进入某个阶段时,它将执行该阶段的特定操作,然后执行该阶段队列中的回调函数,直到队列被清空或执行了最大数量的回调函数。当队列被清空或达到回调函数限制时,事件循环将移动到下一个阶段,依此类推。

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

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

阶段概述

  • timers:此阶段执行由setTimeout()setInterval()安排的回调函数。
  • pending callbacks:执行延迟到下一个循环迭代的 I/O 回调函数。
  • idle, prepare:仅用于内部。
  • poll:检索新的 I/O 事件;执行与 I/O 相关的回调函数(几乎所有回调函数,除了关闭回调函数、由计时器安排的回调函数和setImmediate()安排的回调函数);Node.js 将在适当的时候在此处阻塞。
  • checksetImmediate()回调函数在此处被调用。
  • close callbacks:一些关闭回调函数,例如socket.on('close', ...)

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

阶段详解

timers

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

从技术上讲,poll阶段控制计时器何时执行。

例如,假设您安排了一个计时器在 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 毫秒才能完成)被添加到 **轮询** 队列中并执行。当回调完成时,队列中没有更多回调,因此事件循环将看到最近的计时器阈值已达到,然后返回到 **计时器** 阶段以执行计时器的回调。在此示例中,您将看到计时器被调度到其回调执行之间的总延迟将为 105 毫秒。

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

挂起的回调

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

轮询

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

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

当事件循环进入 **轮询** 阶段并且没有计划的计时器时,将发生以下两种情况之一

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

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

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

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

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

检查

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

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

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

关闭回调

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

setImmediate() vs setTimeout()

setImmediate()setTimeout()类似,但它们的行为取决于调用它们的时间。

  • setImmediate()旨在在当前轮询阶段完成后执行脚本。
  • setTimeout()调度一个脚本,在经过最小阈值(以毫秒为单位)后运行。

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

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

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

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

但是,如果您将这两个调用放在 I/O 周期内,则立即回调始终先执行。

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

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('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 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() 在事件循环的下一个迭代或“滴答”中触发

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

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

为什么要使用 process.nextTick()

主要有两个原因

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

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

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

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

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

假设 listen() 在事件循环的开头运行,但监听回调放在 setImmediate() 中。除非传递主机名,否则绑定到端口将立即发生。为了让事件循环继续,它必须进入 **poll** 阶段,这意味着有一个非零的可能性,即连接可能已经收到,允许连接事件在监听事件之前触发。

另一个例子是扩展 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!');
});