zl程序教程

您现在的位置是:首页 >  Javascript

当前栏目

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

2023-04-18 16:52:07 时间

什么是事件循环?

事件循环通过将操作分给系统内核来处理使得使用单线程的 JavaScript 的 Node.js 可以进行无阻塞 I/O 操作。

由于大部分现代内核都是多线程的,所以可以在后台同时处理多个操作。当有操作完成时,内核会告诉 Node.js,Node.js 将合适的回调加入轮询队列等待被执行。

事件循环解析

在 Node.js 启动的时候,一步步地做了:初始化事件循环,处理可能包含异步 API 调用的输入脚本(用户代码)(或进入 REPL,这里不讲 REPL),调度定时器,或者调用 process.nextTick() 。然后开始处理事件循环。

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

:每一格称为事件循环的一个阶段。

每一阶段都有一个先进先出的待执行任务队列。而在每一阶段内部有自己的执行方法,也就是说,当进入其中一个阶段时,会执行任何该阶段自己特定的操作,然后才执行在该阶段的队列中的回调,直到队列里的回调都执行完了或执行的次数达到最大限制。当队列耗尽或执行的次数达到最大限制时,事件循环进入下一个阶段,如此循环。

由于这些操作可以安排更多别的操作,并且在轮询阶段处理的新事件都是由内核入队的,则轮询事件可以在处理轮询事件时入队。从而长时间运行的回调可以让轮询阶段运行时间长于定时器的阈值。详见后文。

: Windows 和 Unix/Linux 之间对这些的实现存在细微差别,但对于此文而言并不重要。实际上有七到八个步骤,但是我们关心的、Node.js 真正用到的这里都讲到了。

事件循环阶段一览

定时器:这一阶段执行由 setTimeout()setInterval() 设置的回调。 I/O 回调:执行除关闭回调、定时器调度的回调和 setImmediat() 以外的几乎所有的回调。 ide,prepare:仅内部使用。 轮询:获取新的 I/O 事件;适当的时候这里会被阻断。 checksetImmediate() 的回调。 关闭事件回调:如 socket.on('close', ...) 的回调。

在事件循环的每次运行之间, Node.js 会检查是否在等待任何异步 I/O 或定时器,如果两个都没有就自动关闭。

事件循环阶段详解

定时器

定时器在给出的回调后面指定了等待多长时间后执行这个回调,而事实上实际执行这个任务的等待时间往往大于指定的等待时间。定时器给出的回调任务在达到等待时间后会尽可能快地被执行;然而,操作系统调度或运行其他回调任务会使应被执行的任务被延迟执行。

:技术上来说,轮询阶段控制定时器什么时候可以执行回调。

例如,先指定了一个阈值为 100ms 的定时器,然后开始异步读取一个需要用时 95ms 能读完的文件:

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() 还没执行完),所以这里开始等待 fs.readFile() 执行完或有一个定时器达到阈值。这里 95ms 更快到达,即文件先读完然后回调添加到轮询队列并开始执行,而该回调任务需要花费 10ms 来执行。在执行完这个任务以后进入定时器阶段时发现有定时器阈值到了,可以开始执行了,然后开始执行这个定时器回调。在这个例子里,实际等待时间比指定的等待时间多了 5ms。

:为了防止轮询阶段独占事件循环而使得其它阶段一直无法被执行, libuv (一个 实现了 Node.js 事件循环机制和所有异步行为的 C 库)在停止对更多事件的轮询之前也有一个依赖于系统的最大值。

I/O 回调

这一阶段执行一些如 TCP 错误类型这类的系统操作回调。举例来说,如果一个正在尝试连接的 TCP 收到了 ECONNREFUSED ,一些系统要报告这个错误,此时要等待时机,这时这个错误报告就被排入 I/O 回调的队列里。

轮询

这个阶段有两个主要的功能: 1、为阈值已经到了的定时器执行一些脚本后进入2。 2、处理队列里的事件。 当事件循环进入这个阶段且没有定时器时,则:

  • 如果轮询回调队列里不为空,事件循环将遍历回调队列,同步执行队列里的任务直到队列空了或达到依赖于系统的最大值。
  • 如果队列为空,则:
    • 如果存在 setImmediate() ,事件循环将结束这个阶段进入 check 阶段来执行 setImmediate() 的回调。
    • 如果不存在 setImmediate() ,事件循环将等待轮询阶段的回调入队,然后立刻执行这些回调。

一旦轮询队列为空,事件循环将检查是否有阈值到达了的定时器,如果有,事件循环将返回到定时器阶段来执行这些定时器的回调。

check

这个阶段允许我们在轮询阶段完成后立刻执行一些回调。如果轮询阶段变为空闲,并且有 setImmediate() 的回调排队,那么事件循环可能会继续进入 check 阶段,而不是等待轮询回调入队。

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

一般来说,随着代码执行,事件循环最终会到达 check 阶段,在该阶段等待一个传入连接、请求等。然而如果有一个回调里调用了 setImmediate() 且轮询阶段空闲,此时将进入 check 阶段而不是等待轮询阶段的回调任务。

关闭事件回调

如果一个 socket 或 handle 突然关闭(如:socket.destroy() ),这个阶段将发送 close 事件。否则这个 close 事件将通过 process.nextTick() 发送。

setImmediate() VS 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 循环里, setImmediate() 将先被执行:

// 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() 的主要优点是:如果是在一个 I/O 循环里调用, setImmediate() 将总是在任何一个定时器之前被执行。

process.nextTick()

理解 process.nextTick()

也许你注意到了尽管 process.nextTick() 是异步 API 的一部分,但是它不在之前的循环图里。这是因为在技术上 process.nextTick() 并不是事件循环里的一部分。不管事件循环的当前阶段是什么, nextTickQueue 都将在当前操作完成后被执行。

回顾我们的循环图,在任一给定阶段调用 process.nextTick() ,所有由 process.nextTick() 调度的回调将在事件循环继续之前得到解决。这会造成一些不好的情况,因为通过递归调用 process.nextTick() 可以让 I/O 一直处于等待状态,这同时也让事件循环到不了轮询阶段。

为何 process.nextTick() 还存在

为什么像这样的一个方法还存在于 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 以外的)以后返回一个错误给调用者。通过使用 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() 并没有做任何异步的事情。因此,当传入的回调要引用 bar 的时候 bar 被赋值的那一步还没被执行到,此时 bar 还是 undefined

通过在回调里用 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() 放在 listen 外层,让主模块代码先运行完再执行这个 listen

process.nextTick() vs setImmediate()

这两个方法的名字容易引起误解。

  • process.nextTick() 在同一阶段立刻执行
  • setImmediate() 在事件循环的下一迭代或 tick 里执行 从本质上来看它们的名字应该交换下比较好。 process.nextTick()setImmediate() 更 ‘immediate’,但这是过去定好的现在不可能再改了。如果真要交换的话可能破坏一大部分的 npm 包。每天都有很多模块加入 npm 里,这意味着我们每多等一天就有更多可能被影响的包出现。因此它们的名字不会改变。

我们建议开发者在所有情况下都使用 setImmediate() 而不是 process.nextTick() 因为 setImmediate() 更容易被理解(且带来更广泛的兼容性,如浏览器 JS )。

为什么使用 process.nextTick()

有两个原因: 1、让用户处理错误,清理干净不需要的资源,或可能在事件循环继续之前重试一下。 2、有时需要在调用栈被释放之后且在事件循环继续之前运行一些回调。 举个简单的例子:

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

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

这里 listen() 在事件循环的一开始就执行了,但是监听 listening 事件的回调在一个 setImmediate() 里面。除非将主机名传递给这个端口,否则这些将立即发生。此时事件循环要继续下去的话必须到达轮询阶段,这意味着需有一个连接在 listening 事件之前触发。

另一个例子是运行一个继承了 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!');
});