从一道经典面试题洞悉 NodeJS 事件循环的核心原理 1 关于 nextTick 回调和 Promise 回调的先后问题欧莱利曾于 2020 年出版发行过一本介绍分布式系统部署的奇书《Distributed Systems with Node.js》仔细看过的朋友几乎都建议不要看中文版理由是翻译常常不能准确传达作者本意并且书中的实例有些也是错的。例如下面这道本想考察事件循环各阶段执行顺序的经典面试题// package.json: {type: commonjs}// test-cjs.jsconst{readFile}require(node:fs)constlgconsole.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}fromnode:fsimport{fileURLToPath}fromnode:urlconst__filenamefileURLToPath(import.meta.url)constlgconsole.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()绑定参数L6functiondeferred(a,b){console.log(microtask,ab);}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.也难怪网友们吐槽翻译的质量不高原文那个诙谐玩味的腔调完全丧失了注3tick 指的是一个完整的事件循环。令人困惑的是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 ofsetImmediate()callbacks and how they interact with timers in certain scenarios.从 libuv 1.45.0Node.js 20开始定时器在每次事件循环迭代的轮询阶段之后执行。在早期版本中定时器是在轮询之前执行的。为了保持向后兼容性libuv 1.45.0 仍会在进入事件循环之前执行一次定时器。此变更可能会影响setImmediate()回调的计时及其在某些场景下与定时器的交互方式。