5

Node.js事件循环、定时器和process.nextTick()

什么是事件循环?

事件循环允许Node.js执行非阻塞I/O操作 — 尽管JavaScript是单线程的 — 通过尽可能将操作卸载到系统内核。

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

事件循环解释

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

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

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

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

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

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

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

阶段概述

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

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

阶段的细节

timers

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

注意:从技术上讲,轮询阶段控制何时执行定时器。

例如,假设你在100毫秒阈值后调度执行超时,那么你的脚本将异步读取一个耗时95毫秒的文件:

const fs = require('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()尚未完成),所以它将等待剩余的ms数,直到达到最快的定时器阈值,当它等待95毫秒通过,fs.readFile()完成了读取文件,其需要10毫秒完成的回调被添加到轮询队列并执行,当回调结束时,队列中不再有回调,因此事件循环将看到已达到最快定时器的阈值然后回到定时器阶段以执行定时器的回调,在此示例中,你将看到正在调度的定时器与正在执行的回调之间的总延迟将为105毫秒。

注意:为了防止轮询阶段耗尽事件循环,libuv(实现Node.js事件循环的C库以及平台的所有异步行为)在停止轮询更多事件之前,还具有硬性最大值(取决于系统)。

pending callbacks

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

poll

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

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

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

  • 如果轮询队列不为空,则事件循环将遍历其同步执行它们的回调队列,直到队列已用尽,或者达到系统相关的硬限制。
  • 如果轮询队列为空,则会发生以下两种情况之一:

    • 如果setImmediate()已调度脚本,则事件循环将结束轮询阶段并继续执行检查阶段以执行这些调度脚本。
    • 如果setImmediate()尚未调度脚本,则事件循环将等待将回调添加到队列,然后立即执行它们。

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

check

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

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

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

close callbacks

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

setImmediate()setTimeout()

setImmediate()setTimeout()类似,但行为方式不同,取决于他们何时被调用。

  • setImmediate()用于在当前轮询阶段完成后执行脚本。
  • setTimeout()调度在经过最小阈值(以ms为单位)后运行脚本。

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

例如,如果我们运行不在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周期内,则始终首先执行immediate回调:

// timeout_vs_immediate.js
const fs = require('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()的主要优点是setImmediate()将始终在任何定时器之前执行(如果在I/O周期内调度),与存在多少定时器无关。

process.nextTick()

理解process.nextTick()

你可能已经注意到,process.nextTick()没有显示在图解中,即使它是异步API的一部分,这是因为process.nextTick()在技术上不是事件循环的一部分,相反,nextTickQueue将在当前操作完成后处理,而不管事件循环的当前阶段如何。

回顾一下我们的图解,无论何时在给定阶段调用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 has 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(),因为它更容易推理(并且它使代码与更广泛的环境兼容,如浏览器JS)。

为什么要使用process.nextTick()

主要有两个原因:

  1. 允许用户处理错误、清除任何不需要的资源,或者在事件循环继续之前再次尝试请求。
  2. 有时,在调用堆栈已解除但在事件循环继续之前,必须允许回调运行。

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

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

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

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

另一个例子是运行一个函数构造函数,比如继承自EventEmitter,它想在构造函数中调用一个事件:

const EventEmitter = require('events');
const util = require('util');

function MyEmitter() {
  EventEmitter.call(this);
  this.emit('event');
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});

你无法立即从构造函数中发出事件,因为脚本还没有处理到用户为该事件分配回调的位置,因此,在构造函数本身中,你可以使用process.nextTick()来设置回调以在构造函数完成后发出事件,从而提供预期的结果:

const EventEmitter = require('events');
const util = require('util');

function MyEmitter() {
  EventEmitter.call(this);

  // use nextTick to emit the event once a handler is assigned
  process.nextTick(() => {
    this.emit('event');
  });
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});

上一篇:阻塞与非阻塞概述
下一篇:不要阻塞事件循环(或工作池)

博弈
2.5k 声望1.5k 粉丝

态度决定一切