1 关于 nextTick 回调和 Promise 回调的先后问题
欧莱利曾于 2020 年出版发行过一本介绍分布式系统部署的奇书《Distributed Systems with Node.js》,仔细看过的朋友几乎都建议不要看中文版,理由是翻译常常不能准确传达作者本意,并且书中的实例有些也是错的。例如下面这道本想考察事件循环各阶段执行顺序的经典面试题:
// package.json: {"type": "commonjs"}// test-cjs.jsconst{readFile}=require('node:fs')constlg=console.logsetImmediate(()=>lg(1))Promise.resolve().then(()=>lg(2))process.nextTick(()=>lg(3))readFile(__filename,()=>{lg(4)setTimeout(()=>lg(5))setImmediate(()=>lg(6))process.nextTick(()=>lg(7))})lg(8)// -> 8 3 2 1 4 7 6 5第一次实测结果确实和书里一样:
【图1】面试题实测结果
但要是改为ESM模块化规范,结果确实不一样:
// package.json: {"type": "module"}// test-esm.jsimport{readFile}from'node:fs'import{fileURLToPath}from'node:url'const__filename=fileURLToPath(import.meta.url)constlg=console.logsetImmediate(()=>lg(1))Promise.resolve().then(()=>lg(2))process.nextTick(()=>lg(3))readFile(__filename,()=>{lg(4)setTimeout(()=>lg(5))setImmediate(()=>lg(6))process.nextTick(()=>lg(7))})lg(8)// -> 8 2 3 1 4 7 6 5二者差异主要体现在Promise回调和process.nextTick()回调的执行顺序上:
CJS模块化:process.nextTick回调优先;ESM模块化:Promise回调优先。
到Node.js官网一查,官网还为二者的区别 单开了一页 详细解释,可见这个问题当时有多热门。
概括来说,在node事件循环的同一轮迭代周期内,process.nextTick()对应的执行队列被清空后,Promise所在的微任务队列才会被立即清空;
本来ESM版实现应该和CJS版一样,都遵循process.nextTick()回调先于Promise回调
但是,在最新的Node.js版本中,ESM模块已经作为微任务队列的一部分在进行处理了,只能等到当前队列清空后再到下一轮迭代周期去触发nextTick回调所在的执行队列,因此执行顺序看上去是反着的;但逻辑上它们应该属于不同的事件循环的迭代周期:Promise队列在上一轮,nextTick队列在下一轮。
所以,这又引出了另一个很坑的知识点:既然执行顺序上并不等效,为什么官方还要让大家优先考虑 queueMicrotask() 而不是 process.nextTick() 呢?
答:因为出于可移植性和稳定性的考虑:queueMicrotask() API更适用于存在多种JavaScript平台环境的场合,毕竟queueMicrotask()的队列在Node.js底层是由V8模块维护的,而process.nextTick()则是Node.js自己维护的:
【图2】process.nextTick 与 queueMicrotast 在 Node.js 底层分属不同的模块进行维护
如果即不考虑可移植的问题,也不在意刚刚提到的执行顺序的差异,其余情况下二者效果才是差不多的。
最后需要注意的是,queueMicrotask()只接收一个参数,因此无法像process.nextTick()那样从第二个参数起,依次传入第一参数那个回调所需的参数,只能通过闭包或.bind()绑定参数(L6):
functiondeferred(a,b){console.log('microtask',a+b);}console.log('start');queueMicrotask(deferred.bind(undefined,1,2));console.log('scheduled');// Output:// start// scheduled// microtask 3与第六行等效的nextTick版本为:process.nextTick(deferred, 1, 2)。
2 关于 nextTick 引入的递归调用问题
因为一些历史遗留问题,process.nextTick()的诞生晚于setImmediate()。设计人本想让setImmediate()按字面意思,在Node.js事件循环的轮询(Poll)阶段完成后,立即执行紧随其后的检查(Check)阶段内的回调逻辑,即setImmediate()。后来却发现这样还不够及时,最好能在每个阶段完成回调后,立刻执行一些回调,而不急于转到事件循环的下一个阶段,于是才有了process.nextTick()这个“补丁”。这样一来,明明写着Immediate(立刻、马上)的API从实际效果来看并没有那么立刻;反而是让标着nextTick(下一刻、下个迭代)的API抢了风头。所以原书作者才加了一段注释:
- A “tick” refers to a complete pass through the event loop. Confusingly,
setImmediate()takes a tick to run, whereasprocess.nextTick()is more immediate, so the two functions deserve a name swap.
也难怪网友们吐槽翻译的质量不高,原文那个诙谐玩味的腔调完全丧失了:
注3:tick 指的是一个完整的事件循环。令人困惑的是,
setImmediate()需要经过一个循环周期,而process.nextTick()则更快地运行,因此这两个函数的名称其实应当互换。
言归正传。正是因为这个process.nextTick()补丁引入了递归调用的风险,比如回调函数就写成process.nextTick,这样整个代码就会在本阶段回调已清空、下个阶段回调未开始的临界状态一直空转,此时process.nextTick()对应的执行队列将永远无法清空,让下一阶段的正常回调等到肝肠寸断,直接“饿死”后续的I/O操作。
为什么这么明显的Bug还要这样设计呢?因为Node.js中的API的设计风格优先级更高,该风格建议优先处理本阶段出现的错误,然后再开始下个阶段。因此process.nextTick()严格意义上讲并不属于事件循环,无论当前位于事件循环哪个阶段,执行完回调逻辑后都会先清空process.nextTick()队列中的回调逻辑,让错误提前在回调中被解决。
那么设计者是如何化解递归调用风险的呢?答案都在函数签名里:process.nextTick(callback[, ...args]),即在process.nextTick()中支持传入剩余参数...args,作为运行callback回调函数时的实际参数,具体用法如下:
functionapiCall(arg,callback){if(typeofarg!=='string'){returnprocess.nextTick(callback,newTypeError('argument should be string'));}}上述代码中的Error实例对象会被当作callback的第一参数传入回调逻辑中,这样就绕开了无限递归的坑。
3 事件循环各阶段排列顺序的最新变更
原书介绍在事件循环各阶段的排列顺序如下:
【图3】原书给出的 Node.js 事件循环的各个阶段
这在 2020 年和大家理解的顺序是有出入的;戏剧性的是,自从 2023 年 4 月Node.js v20发布后,即从libuv v1.45.0开始,Timers定时器阶段被调整到了Poll轮询阶段的后面,当年那个写法貌似和最新版的顺序【神同步】了(虽然不完全相同):
┌───────────────────────────┐ │ timers │ └─────────────┬─────────────┘ │ v ┌───────────────────────────┐ ┌─>│ pending callbacks │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ idle, prepare │ │ └─────────────┬─────────────┘ ┌───────────────┐ │ ┌─────────────┴─────────────┐ │ incoming: │ │ │ poll │<─────┤ connections, │ │ └─────────────┬─────────────┘ │ data, etc. │ │ ┌─────────────┴─────────────┐ └───────────────┘ │ │ check │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ close callbacks │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ └──┤ timers │ └───────────────────────────┘变更说明 原文如下:
Starting with libuv 1.45.0 (Node.js 20), timers are run after thepollphase in each event loop iteration. In earlier versions, timers were run before polling. To preserve backwards compatibility, libuv 1.45.0 still runs timers once before entering the event loop . This change can affect the timing of
setImmediate()callbacks and how they interact with timers in certain scenarios.
从 libuv 1.45.0(Node.js 20)开始,定时器在每次事件循环迭代的轮询阶段之后执行。在早期版本中,定时器是在轮询之前执行的。为了保持向后兼容性,libuv 1.45.0 仍会在进入事件循环之前执行一次定时器。此变更可能会影响setImmediate()回调的计时及其在某些场景下与定时器的交互方式。