解读setTimeout, promise.then, process.nextTick, setImmediate的执行顺序

最近在看《Node.js 调试指南》的时候遇到有意思的几道题,是关于 setTimeout, promise.then, process.nextTick, setImmediate 的执行顺序。今天抽空记录下这道题的分析过程及背后的原理与知识点。

题目如下:

// 题目一:
setTimeout(() => {
  console.log("setTimeout");
}, 0);
setImmediate(() => {
  console.log("setImmediate");
});
// 题目二:
const promise = Promise.resolve();
promise.then(() => {
  console.log("promise");
});
process.nextTick(() => {
  console.log("nextTick");
});
// 题目三:
setTimeout(() => {
  console.log(1);
}, 0);
new Promise((resolve, reject) => {
  console.log(2);
  for (let i = 0; i < 10000; i++) {
    i === 9999 && resolve();
  }
  console.log(3);
}).then(() => {
  console.log(4);
});
console.log(5);
// 题目四
setInterval(() => {
  console.log("setInterval");
}, 100);
process.nextTick(function tick() {
  process.nextTick(tick);
});

在分析这几道题之前先有必要了解下 node.js 的事件循环

事件循环 Event Loop

我们可以简单理解 Event Loop 如下:

  1. 所有任务都在主线程上执行,形成一个执行栈(Execution Context Stack)
  2. 在主线程之外还存在一个任务队列(Task Queen),系统把异步任务放到任务队列中,然后主线程继续执行后续的任务
  3. 一旦执行栈中所有的任务执行完毕,系统就会读取任务队列。如果这时异步任务已结束等待状态,就会从任务队列进入执行栈,恢复执行
  4. 主线程不断重复上面的第三步

上面第三步中的读取任务队列包括以下 6 个阶段
801336-20191020172427935-416926087

  • timers:执行 setTimeout()和 setInterval()中到期的 callback
  • I/O callbacks:上一轮循环中有少数的 I/O callback 会被延迟到这一轮的这一阶段
  • idle,prepare:仅内部调用
  • poll:最重要的阶段,执行 I/O callback,在某些条件下 node 会阻塞在这个阶段
  • check:执行 setImmediate()的 callback
  • close callbacks:执行 close 事件的 callback,例如 socket.on(‘close’,func)

每个阶段都有一个 FIFO 的回调队列,当 Event Loop 执行到这个阶段时,就会从当前阶段的队列里拿出一个任务放到执行栈中执行,在队列任务清空或者执行的回调数量达到上限后,Event Loop 就会进入下一个阶段

poll 阶段

poll 阶段主要有两个功能,如下所述:

  1. 当 timers 的定时器到期后,执行定时器(setTimeout 和 setInterval)的 callback
  2. 执行 poll 队列里面的 I/O callback
    如果 Event Loop 进入了 poll 阶段,且代码未设定 timer,则可能发生以下的情况:
  • 如果 poll queue 不为空,则 Event Loop 将同步执行 queue 里的 callback,直至 queue 为空,或者执行的 callback 达到系统上限
  • 如果 poll queue 为空,则可能发生以下情况:
    • 如果代码中使用了 setImmediate(),则 Event Loop 将结束 poll 阶段并进入 check 阶段,执行 check 阶段的代码
    • 如果代码中没有使用 setImmediate(),则 Event Loop 将阻塞在该阶段,等待 callback 加入 poll queue,如果有 callback 进来则立即执行

一旦 poll queue 为空,则 Event Loop 将检查 timers,如果有 timer 的时间到期,则 Event Loop 将回到 timers 阶段,然后执行 timer queue

事件循环原理

  1. node 的初始化

    1. 初始化 node 环境。
    2. 执行输入代码。
    3. 执行 process.nextTick 回调。
    4. 执行 microtasks。
  2. 进入 event-loop

    1. 进入 timers 阶段

      • 检查 timer 队列是否有到期的 timer 回调,如果有,将到期的 timer 回调按照 timerId 升序执行。
      • 检查是否有 process.nextTick 任务,如果有,全部执行。
      • 检查是否有 microtask,如果有,全部执行。
      • 退出该阶段。
    2. 进入 IO callbacks 阶段。

      • 检查是否有 pending 的 I/O 回调。如果有,执行回调。如果没有,退出该阶段。
      • 检查是否有 process.nextTick 任务,如果有,全部执行。
      • 检查是否有 microtask,如果有,全部执行。
      • 退出该阶段。
    3. 进入 idle,prepare 阶段:

      • 这两个阶段与我们编程关系不大,暂且按下不表。
    4. 进入 poll 阶段

      • 首先检查是否存在尚未完成的回调,如果存在,那么分两种情况。
        • 第一种情况:
          • 如果有可用回调(可用回调包含到期的定时器还有一些 IO 事件等),执行所有可用回调。
          • 检查是否有 process.nextTick 回调,如果有,全部执行。
          • 检查是否有 microtaks,如果有,全部执行。
          • 退出该阶段。
        • 第二种情况:
          • 如果没有可用回调。
          • 检查是否有 immediate 回调,如果有,退出 poll 阶段。如果没有,阻塞在此阶段,等待新的事件通知。
      • 如果不存在尚未完成的回调,退出 poll 阶段。
    5. 进入 check 阶段。

      • 如果有 immediate 回调,则执行所有 immediate 回调。
      • 检查是否有 process.nextTick 回调,如果有,全部执行。
      • 检查是否有 microtaks,如果有,全部执行。
      • 退出 check 阶段
    6. 进入 closing 阶段。

      • 如果有 immediate 回调,则执行所有 immediate 回调。
      • 检查是否有 process.nextTick 回调,如果有,全部执行。
      • 检查是否有 microtaks,如果有,全部执行。
      • 退出 closing 阶段
    7. 检查是否有活跃的 handles(定时器、IO 等事件句柄)。

      • 如果有,继续下一轮循环。
      • 如果没有,结束事件循环,退出程序。

通过上面的事件循环的介绍我们已经知道 setTimeout setImmediate 的执行机制,但是并没有介绍 process.nextTick()和 promise.then()。这里我们还需要知道宏任务与微任务的概念

宏任务 Macrotask

宏任务是指 Event Loop 在每个阶段执行的任务
宏任务包括 script (整体代码),setTimeout, setInterval, setImmediate, I/O, UI renderin

微任务 Microtask

微任务是指 Event Loop 在每个阶段之间执行的任务
微任务包括 process.nextTick, Promise.then,Object.observe,MutationObserver

宏任务与微任务执行顺序图
801336-20191020172507873-569608539

图中绿色小块表示 Event Loop 的各个阶段,执行的是宏任务,粉色箭头表示执行的是微任务

了解到这里我们再来分析上面的几道题
题目一的执行结果是:

setTimeout
setImmediate
//或者
setImmediate
setTimeout

为什么结果不确定呢?我们知道 setTimeout 的回调函数在 timer 阶段执行,setImmediate 的回调函数在 check 阶段执行。但是从事件循环开始到 timer 阶段会消耗一定的时间,所以会出现两种情况:

  1. 若 timer 前的准备时间超过 1ms,则执行 timer 阶段(setTimeout)的回调函数
  2. 若 timer 前的准备时间少于 1ms,则执行 check 阶段(setImmediate)的回调函数,下次 event loop 循环在执行 timer 阶段的函数

题目二的执行结果是

nextTick
promise

这里虽然和 process.nextTick 一样,promise.then 也将回调函数注册到 microtask,但 process.nextTick 的 microtask queue 总是优先于 promise 的 microtask queue 执行的
题目三的执行结果是

2
3
5
4
1

Promise 构造函数是同步执行的,所以先打印 2,3,在打印 5,接下来事件循环执行微任务执行 promise.then 的回调,打印 4,然后进入下一个事件循环执行 timer 阶段的回调打印 1

题目四的执行结果是
永远不会打印 setInterval

process.nextTick 会无限循环,将 event loop 阻塞在 microtask 阶段,导致 event loop 上其他 macrotask 阶段的回调函数没有机会执行
解决方法通常是用 setImmediate 代替 process.nextTick.
在 setImmediate 内执行 setImmedaite 时会将 immediate 注册到下一次 event loop 的 check 阶段,这样其他 macrotask 就有机会执行

至此终于将 node.js 事件循环宏任务与微任务分析清楚了