zhangxiang958/Blog

[译]Promises, Next-Ticks 和 Immediates— NodeJS Event Loop Part 3

zhangxiang958 opened this issue · 0 comments

原文链接:https://jsblog.insiderattack.net/promises-next-ticks-and-immediates-nodejs-event-loop-part-3-9226cbe7a6aa
欢迎回到事件循环系列文章!在第一篇文章中,我讲述了 NodeJS 事件循环的整体概述与它在不同阶段的行为。在第二篇文章中我们谈论了定时器与 immediates 在事件循环中的行为与它们是如何运作的。在本篇文章中,我们将会探讨事件循环是如何编排处理 resolved/rejected promises(包括原生 JS promises,Q promises 和 bluebird Promise)与 next tick 回调函数。如果你不太熟悉 Promise,那么我建议你可以先接触一下 Promise 相关知识。

文章系列目录

Native Promises

在原生 Promise 上下文中,一个 promise 回调可以被看作是一个 microtask(微任务)并且它将会被在 microtask 队列中排队等待在 next tick 队列被清空后被处理执行。

请看以下代码:

Promise.resolve().then(() => console.log('promise1 resolved'));
Promise.resolve().then(() => console.log('procise2 resolved'));
Promise.resolve().then(() => {
    console.log('promise3 resolved');
    process.nextTick(() => console.log('next tick inside promise resolve handler'));
});
Promise.resolve().then(() => console.log('promise4 resolved'));
Promise.resolve().then(() => console.log('promise5 resolved'));
setImmediate(() => console.log('set immedaite1'));
setImmediate(() => console.log('set immediate2'));

process.nextTick(() => console.log('next tick1'));
process.nextTick(() => console.log('next tick2'));
process.nextTick(() => console.log('next tick3'));

setTimeout(() => console.log('set timeout'), 0);
setImmediate(() => console.log('set immediate3'));
setImmediate(() => console.log('set immediate4'));

在上面的例子中,实际发生了下面这些操作:

  1. 5 个处理函数被添加到 microtask 任务队列中。(注意这 5 个处理函数都是由 5 个 resolve promise 添加的)
  2. 2 个处理函数通过 setImmediate 被添加到 immediate 队列中。
  3. 3 个 next tick 回调函数被 process.nextTick 添加到 next tick 队列中。
  4. 1 个过期时间为 0 的定时器被创建,它会立即到期并将回调添加到定时器队列中。
  5. 2 个处理函数通过 setImmediate 添加到 immediate 队列中。

然后事件循环将会开始检查 next tick 队列。

  1. 事件循环通过检查得知此时有三个待处理的 next tick 回调,然后 Node 将会执行它们直到队列为空。
  2. 然后事件循环会检查 promises 微任务队列,并且知道队列中有 5 个回调需要被执行,并开始执行这些回调。
  3. 在处理 promises 微任务队列的过程中,有一个 next tick 回调被添加到 next tick 队列中('next tick inside promise resolve handler')。
  4. 在 promises 微任务队列完成之后,事件循环将会得知 next tick 队列中有一个回调通过 promises 微任务添加到了队列中,然后 node 会再一次执行 next tick 队列中的那一个回调任务。
  5. 在执行完 promises 和 next tick 的所有任务之后,事件循环会移动到第一个阶段即定时器阶段,此时它将会发现在定时器队列中有一个到期的定时器回调需要被执行,然后执行该回调。
  6. 执行完定时器队列中所有回调之后,事件循环等待 I/O 操作。因为此时我们没有等待中的 I/O 事件,那么事件循环则会移动到 immediate 队列阶段。它将会检测到有 4 个待执行处理的回调,事件循环会将它们逐一执行。
  7. 最后,事件循环完成了所有事件... 然后程序退出。

你是否被通篇的 "promises 微任务"而不是微任务弄烦了?

我知道这个会比较痛苦,但是你知道 resolved/rejected 的 promises 与 process.nextTick 添加的回调都同属于微任务,因为,我没有办法说它们是 nextTick 队列和微任务队列。

我们来一起看一下上面例子的输出。

next tick1
next tick2
next tick3
promise1 resolved
promise2 resolved
promise3 resolved
promise4 resolved
promise5 resolved
next tick inside promise resolve handler
set timeout
set immediate1
set immediate2
set immediate3
set immediate4

Q 和 Bluebird

很好!你现在知道了原生的 JS Promise 的回调会被当作微任务来处理,并且会在事件循环在进入下一个阶段之前被执行。那么,QBluebird 生成的 Promise 呢?

在 NodeJS 支持原生的 promise 对象之前,人们过去会使用像 Q 或者 Bluebird 这样的库来代替。因为这些库的实现早于原生的 promises,它们与原生的 promises 会有一些区别。

在本篇文章的时间节点,根据 Q 的文档,Q(v1.5.0) 使用 process.nextTick 队列来编排处理那些 resolved/rejected 的 promise 的回调。

注意,promise 的实现始终是异步的,也就是说,无论是 fulfillment 或者 rejection 的回调都将会在事件循环的下一个阶段才会被执行(例如 process.nextTick)。这也保证了 then 的回调函数始终会在其他事件阶段之前被执行。

在另一方面,Bluebird,在本篇文章的时间节点,它的版本为 3.5.0,在大于 0.10 的 Node 版本中使用了 setImmedioate 来编排处理 promise 的回调。(你可以在这里查看代码)。

为了研究透彻,我们来看一下以下例子:

const Q = require('q');
const Bluebird = require('bluebird');

Promise.resolve().then(() => console.log('native promise resolved'));
Bluebird.resolve().then(() => console.log('bluebird promise resolved'));
setImmediate(() => console.log('set immediate'));
Q.resolve().then(() => console.log('q promise resolved'));
process.nextTick(() => console.log('next tick'));
setTimeout(() => console.log('set timeout'), 0);

在上面这个例子中,Bluebird.resolve().then 回调函数和下面的 setImmediate 一致,因此,bluebird 的回调和 setImediate 回调一样被排入 immediate 队列中。因为 Q 使用 process.nextTick 来实现它的 resolve/reject 的异步回调,Q.resolve().then 的回调会在后面的 process.nextTick 添加的回调之前。看一下输出结果:

q promise resolved
next tick
native promise resolved
set timeout
bluebird promise resolved
set immediate

请注意,虽然我上面的代码只使用了 resolve 的回调,但是 reject 的回调与其行为一致。在文章的最后,我将会提供一个 resolve 和 reject 回调都使用的例子。

其实 Bluebird 提供了一个可选机制,我们可以选择我们想要的异步实现机制,这是不是意味着我们可以选择 process.nextTick 或者 setImmediate 来实现底层的异步调用?是的,没错,Bluebird 提供了一个叫做 setScheduler 的函数,它可以接受一个函数,这个函数将会代替 setImmediate 成为 bluebird promise 底层的异步实现机制。

如果需要使用 process.nextTick 作为异步机制:

const BlueBird = require('bluebird');
BlueBird.setScheduler(process.nextTick);

如果需要使用 setTimeout 来作为异步机制:

const BlueBird = require('bluebird');
BlueBird.setScheduler((fn) => {
    setTimeout(fn, 0);
});

——为了不让文章篇幅太长,我将不会提供太多关于 bluebird 的使用例子,你可以自己尝试不同的异步实现机制——

使用 setImmediate 来实现异步机制会比 process.nextTick 要来得合理。因为从 NodeJS v0.12 开始,已经没有 process.maxTickDepth 参数配置了,不断地将回调函数添加到 next tick 队列中,会引起 I/O 饿死问题。因此,使用 setImmediate 来实现底层异步机制要比使用 process.nextTick 要来得安全,因为如果 next tick 队列中没有回调函数需要执行, immediate 队列会在 I/O 操作之后被执行,这样不会阻塞 I/O 操作的执行。

最后一点!

请看以下程序及其输出结果:

const Q = require('q');
const BlueBird = require('bluebird');

Promise.resolve().then(() => console.log('native promise resolved'));
BlueBird.resolve().then(() => console.log('bluebird promise resolved'));
setImmediate(() => console.log('set immediate'));
Q.resolve().then(() => console.log('q promise resolved'));
process.nextTick(() => console.log('next tick'));
setTimeout(() => console.log('set timeout'), 0);
Q.reject().catch(() => console.log('q promise rejected'));
BlueBird.reject().catch(() => console.log('bluebird promise rejected'));
Promise.reject().catch(() => console.log('native promise rejected'));
q promise resolved
q promise rejected
next tick
native promise resolved
native promise rejected
set timeout
bluebird promise resolved
bluebird promise rejected
set immediate

你现在是否有两个疑问?

  1. 如果 Q 是使用 process.nextTick 来实现底层异步机制编排 resolved/rejected 的 promise 回调,上面的结果为什么 q promise rejected 会出现在 next tick 之前?
  2. 如果 Bluebird 是使用 setImmediate 来实现底层异步机制编排 resolved/rejected 的 promise 回调,上面的结果为什么 bluebird promise rejected 出现在 set immediate 之前?

这是因为这两个库在内部都维护了一个队列,当出现一个 resolved/rejected 的 promise 的回调的时候,会初始化一个队列,并将这个回调函数加入到这个队列中,当后续在同一周期中又有 resolved/rejected 的回调的时候,会将回调添加到这个队列里面,而不是新开一个 process.nextTick 或者 setImmediate 来添加回调到事件队列中,所以看起来就像是:

q/bluebirld 内部队列 [(q promise resolved), (q promise rejected)]
next tick

很好!你现在知道了关于 setTimeout,setImmediate,process.nextTick 和 promises 的执行机制,可能对于一个例子你已经能够清晰地理出它的输出结果了。如果你对以上内容有任何意见,请在评论区回复我。

References