前端面试-javascript事件循环机制(node)

116 阅读5分钟

node环境

node环境下的事件循环是通过libuv实现,和在浏览器里的事件循环有着很大的区别,其中将每一次轮循分成6个阶段。

六个阶段

  • timers阶段:执行setTimeout和setInterval中定时完成的回调函数,其中定时器有可能因为系统调度的问题或者由于其他回调导致不准确情况
  • I/O callbacks阶段:上一轮循环部分I/O callback会被延迟到这一轮的这个阶段执行。主要执行一些系统操作错误回调,如stream、tcp、udp通信错误等
  • idle,prepare阶段:node内部使用
  • poll阶段:除了timer、close、check以外的任务,都会将回调函数放入到这个阶段中的任务队列中,一定条件下,node会阻塞在这里
  • check阶段:执行setImmediate设置的callback
  • close callbacks 阶段:套接字或处理函数关闭,通过 close 定义的回调函数就会在这个阶段执行,如执行 socket.destroy 后,socket.on('close', callback)定义的callback 就会存放在本阶段的任务队列中。

每一个阶段都有一个用来存放回调函数的任务队列。node离开某个阶段需要满足后面的条件,node将任务队列中的回调执行完毕或者任务队列中的回调数超过最高限制之后,就会离开这个阶段。

其中timer阶段、poll阶段、check阶段是需要重点介绍的阶段;

timers阶段

setTimeout和setInterval定义的超过定时的回调函数将会存放到这个阶段中的任务队列中,当运行到这个阶段的时候,就会依次将回调函数取出来并执行,node中的记时器定时任务定时最小是 1。

poll阶段

poll阶段执行的是I/O回调函数,当异步I/O任务执行完成的时候,就会将他们的回调函数压入到任务队列中,node处于这个阶段的时候就会将该阶段存放的任务队列中的回调函数执行完。

poll阶段有以下两个重要的功能:

  • 处理本阶段任务队列中的回调:执行完任务队列中的任务或者执行的任务数到达系统上限时就会离开该阶段
  • 当poll queue为空的时候,检测timers中的任务队列是否为空
    • timers中任务队列不为空,event loop就会按照前面列出来的那六个阶段顺序循环进入到timers阶段,并执行该阶段中的任务队列
    • timers中的队列为空,check阶段如果不为空,就会结束poll阶段,进入到check阶段,并执行check阶段中的任务队列;
    • timers中的队列为空,check阶段如果为空,事件循环就会阻塞在这个阶段。等待后面的callback加入到这个阶段的任务队列中,然后运行;检测timers阶段是否有任务待执行;检测check阶段是否有任务待执行
check阶段

这个阶段执行都是setImmediate定义的回调,当这个阶段中的任务队列不为空的时候,会让 event loop 暂时不阻塞在 poll 阶段。

process.nextTick

process.nextTick不属于上面提到的任何阶段,每个阶段结束的时候都会执行完nextTick任务队列中的回调,并且在进入新的一轮loop的时候就会有一次机会去清空nextTick的回调。

microtask

nextTick中的任务队列执行完以后,还有其他的工作,如执行microtask,如promise回调。

测试

const fs = require('fs'); 
const readerFile = callback => { fs.readFile('./file.txt', callback); }; 
const startTime = Date.now(); let readEndTime = 0; 
setTimeout(() => { 
    const delay = Date.now() - startTime; 
    console.log('延迟执行计时器callback:', delay); 
    console.log('读取文件耗时:', readEndTime - startTime); 
}
, 10) 
readerFile(() => { 
    readEndTime = Date.now();
     while (Date.now() - readEndTime < 20) {} 
}); 
// 执行结果 延迟执行计时器callback: 23 
// 读取文件耗时: 3

整个流程如下:

  • 启动程序,运行其他同步代码,初始化event loop
  • timers阶段,检测到任务队列为空,定时器设置的是 10ms,此时计时还没有超过这个10ms。
  • I/O callback 回调,没有任务
  • idle,prepare 忽略
  • poll阶段,任务队列为空,timers queue 为空,check queue 为空,此时阻塞在 poll 阶段。3ms或者4ms之后文件读取完成,将定义的callback被压入poll queue,重队列中取出并执行回调函数,执行这个回调函数花费20ms(定时器会在执行这个回调函数的时候完成,然后将回调压入timers queue中)。callback执行完成以后,poll queue 空闲,检测timers queue是否为空,timers queue 队列不为空,因此,退出poll阶段,继续走到后面阶段,直到回到timers阶段,途径 check阶段、close callback阶段,到达timers阶段,执行timers queue中的回调,这里虽然定时器设置的10ms就执行回调,但是实际被延迟到23ms后才被执行。
const fs = require('fs');

fs.readFile('./file.txt', () => {
    setTimeout(() => {
        console.log('timeout');
    });
    setImmediate(() => {
        console.log('immediate');
    });
});

// 结果只有一个
//immediate
// timeout

第一个tick,loop会阻塞在 poll 阶段,直到文件读取完成,将回调函数压入poll queue。检测到poll queue不为空,取出并执行回调函数,回调函数中执行 setTimeout生成一个定时任务(计时为1ms),执行setImmediate,向check queue压入回调函数。poll阶段的queue为空后,检测timers queue是否为空,检测check queue是否为空(实际上node中不管是timers还是check中的任务队列不为空的时候,都会经过这两个阶段,然后再阻塞在poll阶段)。执行poll阶段回调后的具体流程如下(这里假设定时任务已完成):

  • timers queu不为空,进入check阶段,执行check queue中的回调函数。
  • 进入下一个loop,进入timers阶段,执行任务队列中的回调函数

setTimeout和setImmediate这两个异步函数函数放到一个I/O回调函数中的时候,setImmediate回调始终优先调用,是由六个阶段的执行顺序决定的。

setTimeout(() => {
    console.log('timeout')
    process.nextTick(() => {
        console.log('nextTick 2')

        Promise.resolve().then(() => {
            console.log('promise 1')
        })
    })
})
process.nextTick(() => {
    console.log('nextTick 1')

    Promise.resolve().then(() => {
        console.log('promise 2')
    })
})
setImmediate(() => {
    console.log('setImmediate');
})

// 执行结果如下
//nextTick 1
//promise 2
//timeout
//nextTick 2
//promise 1
//setImmediate