深入理解JS事件循环机制(Node)

2022/02/10 JavaScript 共 2675 字,约 8 分钟

示例

从实践出发,先看一个例子

setTimeout(() => {
  console.log('timer1')
  Promise.resolve().then(function() {
    console.log('promise1')
  })
}, 0)

setTimeout(() => {
  console.log('timer2')
  Promise.resolve().then(function() {
    console.log('promise2')
  })
}, 0)

看一下在node环境下的输出结果

timer1
timer2
promise1
promise2

先简单分析一下执行过程: node-excute-animate

首先进入 timers 阶段,执行timer1的回调函数,打印timer1,并将promise1.then 回调放入 microtask 队列,同样的步骤执行timer2,打印timer2;至此,timer阶段执行结束,event loop 进入下一个阶段之前,microtask 队列的所有任务依次打印promise1promise2

Node.js的事件处理

Node.js采用V8作为js的解析引擎,而I/O处理使用了自己设计的libuv。什么是libuv,可以查看这篇文章(libuv介绍) 根据Node.js官方介绍,每次循环都包含6个阶段,如图所示: node

  • timers 阶段: 执行setTimeoutsetInterval中到期的callback
  • I/O callbacks 阶段: 上一轮循环中的少数的callback会放在这一阶段执行
  • idle, prepare 阶段: 仅在node内部使用
  • poll 阶段: 最重要的阶段,获取新的I/O事件,适当条件下node会阻塞在这个阶段
  • check 阶段: 执行setImmediate()的回调(setImmediate将事件插入到事件队列的尾部,主线程和事件队列的函数执行完之后立即执行setImmediate
  • close callbacks 阶段: 执行socketclose的回调函数,比如socket.on('close', callback)的 callback 会在这个阶段执行

每一个阶段都有一个装有 callback 的fifo queue(队列),当 event loop 运行到一个指定阶段时,Node将执行该阶段的队列,当队列 callback 执行完或执行 callbacks 数量超过该阶段上限时,event loop 会转入下一个阶段。

重点看timerspollcheck3个阶段,具体细节如下:

timer

timer是事件循环的第一个阶段,Node会去检查setTimeoutsetInterval中有无过期的callback,如果有则把它的回调压入timer的任务队列中等待执行。

poll

在nide.js里,任何异步方法(除timerclosesetImmediate外)完成时,都会将其 callback 加到 poll queue 里,并立即执行。

poll阶段主要有2个功能:

  • 处理 poll 队列的事件
  • 当到达 timers 指定的时间时,执行 timers 的 callback

event loop进入poll阶段会出现两种情况:

1.timer queue 为空

  • 如果 poll queue 不为空,event loop 将同步执行队列中的 callback,直到队列为空或者执行的 callback 到达系统上限
  • 如果 poll queue 为空,又分为两种情况:
    • 如果有预设的setImmediate(), event loop 将结束 poll 阶段进入 check 阶段,并执行 check 阶段的任务队列
    • 如果没有预设的setImmediate(), event loop 将阻塞在该阶段等待 callbacks 加入 poll queue

2.timer queue 不为空

  • 如果 poll queue 进入空状态,event loop 将检查 timers ,如果有 timers 时间已到达,event loop 将按循环进入 timers 阶段,并执行 timer queue

check

此阶段允许在 poll 阶段完成后立即执行回调。如果 poll 阶段空闲并且队列中有setImmediate(),event loop 会继续执行到 check 阶段而不是继续等待。

setImmediate() 和 setTimeout() 的区别

  • setImmediate()用于在当前poll阶段完成后check阶段执行脚本
  • setTimeout()在经过以毫秒(ms)为单位的最小阙值后,在timers阶段执行

举个例子:

const fs = require('fs')

fs.readFile('a.txt', () => {
  console.log('readFile')
  setTimeout(() => {
    cosnole.log('timeout')
  }, 0)
  setImmediate(() => {
    console.log('immediate')
  }, 0)
})

执行结果

readFile
immediate
timeout

immediatetimeout之前打印,主要原因是在I/O阶段读取文件后,event loop 会先进入poll阶段,发现有setImmediate()需要执行,会立即进入check阶段执行setImmediate()的回调,然后再进入timers阶段,执行setTimeout()的回调。

process.nextTick()

process.nextTick()会在各个事件阶段之前执行,一旦执行,要直到nextTick队列被清空,才会进入到下一个事件阶段。所以如果递归调用process.nextTick()会导致 I/O starving 的问题。

例子:

setTimeout(() => {
  console.log('timeout')
})

setImmediate(() => {
  console.log('immediate')
})

function apiCall(cb) {
  process.nextTick(cb)
}

apiCall(() => {
  console.log('process.nextTick')
})

打印结果为

process.nextTick
immediate
timeout
// 或者
// process.nextTick
// timeout
// immediate

参考文章:

1.event-loop-timers-and-nexttick
2.深入理解js事件循环机制(Node.js篇)
3.一次弄懂Event Loop
4.Node.js Event Loop 的理解 Timers,process.nextTick()