nodejs中事件循环机制与面试题详解
阅读原文时间:2023年10月18日阅读:1

nodejs中架构如下图所示,通过v8引擎来执行js代码,通过中间层 libuv 来读写文件系统、网络等做一些操作。

nodejs中提供阻塞和非阻塞的调用方式,比如fs模块中读取文件,可以根据需要使用 readFile(异步) 或者 readFileSync(同步)。

如果使用同步的编程方式,那么后续代码的执行需要等到此次执行结束,程序的运行会被“阻塞”。也可以使用异步的编程方式,后续代码的执行无需等待此次的执行结束,不会“阻塞”程序的运行,但它同样也会存在一个问题,那就是非阻塞式调用需要不断的轮询获取异步调用的执行结果。

libuv中的io操作就是使用的”非阻塞式调用“,但它如果不断轮询获取结果这个过程对系统有一定的性能影响,为了将这个影响降低,libuv中将不断轮询的这个过程放到“线程池”当中,当轮询到结果时,将对应的回调和获取的结果放置到 event loop(事件循环)的某一个队列中,事件循环再来进行下一步的操作,通过javascript来执行回调。

nodejs中的事件循环要比javascript的事件循环(设置一个链接)更为复杂一些,一次循环分为以下几个阶段

* 定时器(timer): 在这个阶段执行 setTimeout、setInterval的回调函数
* 待定回调(pending callbacks):某些系统操作(如 TCP 错误类型)执行回调
* idle, prepare:仅系统内部使用。
* 轮询(poll):检索新的 I/O 事件; 执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和 setImmediate() 调度的之外)
* 检测(check):setImmediate() 回调函数在这里执行。
* 关闭的回调函数(close callbacks):一些关闭的回调函数,如:socket.on('close', …)。

nodejs中也和javascript中一样存在着微任务(micro-task)、宏任务(macro-task),两个任务中执行的内容也有一部分的相似性

微任务:promise的then函数的回调、queueMicrotask、process.nextTick
宏任务: setTimeout、setInterval、io事件、setImmediate、close事件

执行顺序也和javascript中一致,先执行主线程的任务,然后接着执微任务,微任务执行完成再执行宏任务,具体的执行顺序如下。

微任务队列
next tick queue:process.nextTick
other tick queue:promise的then函数、queueMicrotask

宏任务队列
timer queue: setTimeout、setInterval
poll queue: io事件
check queue: setImmediate
close queue: close事件

了解完nodejs中事件循环的执行顺序之后,一起来看看下面这道面试题

async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}

async function async2() {
console.log('async2')
}

console.log('script start')

setTimeout(function () {
console.log('setTimeout0')
}, 0)

setTimeout(function () {
console.log('setTimeout2')
}, 300)

setImmediate(() => console.log('setImmediate'));

process.nextTick(() => console.log('nextTick1'));

async1();

process.nextTick(() => console.log('nextTick2'));

new Promise(function (resolve) {
console.log('promise1')
resolve();
console.log('promise2')
}).then(function () {
console.log('promise3')
})

console.log('script end')

首先声明了 async1 和 async2函数,只有调用才会被放入调用栈中,所以此时不会执行,往下执行输出 "script start"。

继续向下执行,将 "setTimeout0" 放入 宏任务的 timer queue 中,继续执行,"setTimeout2" 需要延迟300ms执行,不放入 timer queue中,接着把 "setImmediate" 放入 check queue 中,把 "nextTick1" 放入 next tick queue 中。

此时执行函数 async1,输出 "async1 start",在函数 async1 中执行 async2,输出 "async2",把 "async1 end" 放入 other queue当中,此时函数 async1执行完成,继续向下执行,将"nextTick2"放入next tick queue中,排在"nextTick1"后面,继续输出 "promise1",将"promise3"放在other queue中,排在"async1 end"后面,输出"promise2",最后输出"script end",此时主线程的内容都执行完成。

来到微任务队列,微任务队列中先执行 next tick queue 中的内容,依次输出 "nectTick1"、"nectTick2",再执行 other queue 中的内容,依次输出"async1 end"、"promise3"。

最后执行宏任务队列中的任务,先执行 timer queue,输出 "setTimeout0",这里没有io事件(poll),往下执行check quue,输出 setImmediate,没有关闭回调函数阶段( close callbasks),一次事件循环结束,来到第二次、第三次事件循环,此时300ms后,"setTimeout2" 被加入到宏任务队列中的 timer queue,事件循环中没有其他的队列,直接输出 "setTimeout2",事件循环结束。

简单图示如下

再来看另一道面试题

setTimeout(() => {
console.log("setTimeout");
}, 0);

setImmediate(() => {
console.log("setImmediate");
});

按照宏任务队列中各任务的执行顺序,setTimeout属于timer queue,setImmediate属于check queue,按理说会先输出setTimeout,但实际情况会是什么样的呢,我们看一下以下输出情况

为什么会这样的情况呢,有时候是setTimeout先输出,有时候是setImmediate先输出,原因在于,setTimeout的回调函数虽然是延迟0毫秒执行,但是 setTimeout的准备时间要长于 event loop 的启动时间,当event loop 开始第一次循环的时候,setTimeout 还没有被放入 timer queue 之中,所以 event loop 先执行 check queue 中的 setImmediate,等待第二次循环的时候,timer queue 中才有setTimeout,此时就会出现 setImmediate 先输出的情况。 

nodejs中的事件循环机制有一部分和javascript中事件循环是一致的,如果对javascript事件循环机制还不太熟悉,可以看看这一篇文章,javascript事件循环机制及面试题详解