timer 用于安排函数在未来某个时间点被调用,Node.js 中的定时器函数实现了与 Web 浏览器提供的定时器 API 类似的 API,但是使用了事件循环实现,Node.js 中有四个相关的方法

  1. setTimeout(callback, delay[, ...args])
  2. setInterval(callback[, ...args])
  3. setImmediate(callback[, ...args])
  4. process.nextTick(callback[, ...args])

前两个含义和 web 上的是一致的,后两个是 Node.js 独有的,效果看起来就是 setTimeout(callback, 0),在 Node.js 编程中使用的最多。

Node.js 不保证回调被触发的确切时间,也不保证它们的顺序,回调会在尽可能接近指定的时间被调用。setTimeout 当 delay 大于 2147483647 或小于 1 时,则 delay 将会被设置为 1, 非整数的 delay 会被截断为整数

奇怪的执行顺序

看一个示例,用几种方法分别异步打印一个数字

  1. setImmediate(console.log, 1);
  2. setTimeout(console.log, 1, 2);
  3. Promise.resolve(3).then(console.log);
  4. process.nextTick(console.log, 4);
  5. console.log(5);

会打印 5 4 3 2 1 或者 5 4 3 1 2

同步 & 异步

第五行是同步执行,其它都是异步的
所以先打印 5,这个很好理解,剩下的都是异步操作,Node.js 按照什么顺序执行呢?

  1. setImmediate(console.log, 1);
  2. setTimeout(console.log, 1, 2);
  3. Promise.resolve(3).then(console.log);
  4. process.nextTick(console.log, 4);
  5. /****************** 同步任务和异步任务的分割线 ********************/
  6. console.log(5);

Event Loop

Node.js 启动后会初始化事件轮询,过程中可能处理异步调用、定时器调度和 process.nextTick(),然后开始处理event loop。官网中有这样一张图用来介绍 event loop 操作顺序。

  1. ┌───────────────────────────┐
  2. ┌─>│ timers
  3. └─────────────┬─────────────┘
  4. ┌─────────────┴─────────────┐
  5. pending callbacks
  6. └─────────────┬─────────────┘
  7. ┌─────────────┴─────────────┐
  8. idle, prepare
  9. └─────────────┬─────────────┘ ┌───────────────┐
  10. ┌─────────────┴─────────────┐ incoming:
  11. poll │<─────┤ connections,
  12. └─────────────┬─────────────┘ data, etc.
  13. ┌─────────────┴─────────────┐ └───────────────┘
  14. check
  15. └─────────────┬─────────────┘
  16. ┌─────────────┴─────────────┐
  17. └──┤ close callbacks
  18. └───────────────────────────┘

各个阶段主要任务

  1. timers:执行 setTimeout、setInterval 回调
  2. pending callbacks:执行延迟到下一个循环的 I/O 回调(文件、网络等)
  3. idle, prepare:仅供系统内部调用
  4. poll:获取新的 I/O 事件,执行相关回调,其余情况 node 将在适当的时候在此阻塞
  5. check:setImmediate 回调在此阶段执行
  6. close callbacks:执行 socket 等的 close 事件回调

日常开发中绝大部分异步任务都是在 timers、poll、check 阶段处理的

event loop 的每个阶段都有一个任务队列,当 event loop 进入给定的阶段时,将执行该阶段的任务队列,直到队列清空或执行的回调达到系统上限后,才会转入下一个阶段,当所有阶段被顺序执行一次后,称 event loop 完成了一个 tick。

异步操作都被放到了下一个 event loop tick 中,process.nextTick 在进入下一次 event loop tick 之前执行,所以肯定在其它异步操作之前。

  1. setImmediate(console.log, 1);
  2. setTimeout(console.log, 1, 2);
  3. Promise.resolve(3).then(console.log);
  4. /****************** 下次 event loop tick 分割线 ********************/
  5. process.nextTick(console.log, 4);
  6. /****************** 同步任务和异步任务的分割线 ********************/
  7. console.log(5);

1. timer

timers 是事件循环的第一个阶段,Node 会去检查有无已过期的 timer。如果有,则把它的回调压入timer 的任务队列中等待执行。 :::warning 事实上,Node 并不能保证 timer 在预设时间到了就会立即执行,因为 Node 对 timer 的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲。 ::: 下面的代码,多次执行可能会发现打印的顺序不一样。

  1. setTimeout(() => {
  2. console.log('timeout');
  3. }, 0);
  4. setImmediate(() => {
  5. console.log('immediate');
  6. });
  1. 如果机器性能一般,那么进入 timers 阶段,1 ms 已经过去了,那么setTimeout的回调会首先执行。
  2. 如果机器性能牛逼,那么进入 timers 阶段,1 ms 还没到,setTimeout 回调不执行。事件循环来到了 poll 阶段,这个时候队列为空,此时有 setImmediate的回调,于是执行了 check 阶段,之后在下一个事件循环再执行setTimemout的回调函数。

但是把它们放到一个 I/O 回调里面,就一定是 setImmediate() 先执行,因为 poll 阶段后面就是 check 阶段。

  1. const fs = require('fs')
  2. const callback = (err, data) => {
  3. setTimeout(() => {
  4. console.log('timeout');
  5. }, 0);
  6. setImmediate(() => {
  7. console.log('immediate');
  8. });
  9. }
  10. fs.readFile('./xxx.txt', callback)

2. poll

poll 阶段主要有两个任务

  1. 计算应该阻塞和轮询 I/O 的时间
  2. 然后,处理 poll 队列里的事件

当 event loop 进入 poll 阶段且没有被调度的计时器时,将发生以下两种情况之一:

  • 如果 poll 队列不是空的 ,event loop 将循环访问回调队列并同步执行,直到队列已用尽或者达到了系统或达到最大回调数
  • 如果 poll 队列是空的
    • 如果有 setImmediate() 任务,event loop 会在结束 poll 阶段后进入 check 阶段
    • 如果没有 setImmediate() 任务,event loop 阻塞在 poll 阶段等待回调被添加到队列中,然后立即执行

一旦 poll 队列为空,event loop 将检查 timer 队列是否为空,如果非空则进入下一轮 event loop

3. check

在该阶段执行 setImmediate 回调

Promise.then 与 setTimeout 的顺序

前端同学肯定都听说过 micoTask 和 macroTask,Promise.then 属于 microTask。
下面我们用代码来举例子:

  1. setTimeout(() => {
  2. console.log('timer1')
  3. Promise.resolve().then(() => {
  4. console.log('promise1');
  5. })
  6. })
  7. setTimeout(() => {
  8. console.log('timer2')
  9. Promise.resolve().then(() => {
  10. console.log('promise2');
  11. })
  12. })
  1. 在浏览器环境下,按照其事件循环的规则,打印顺序为 timer1 —> promise1 —> timer2 —> promise2

    在浏览器环境下 microTask 任务会在每个 macroTask 执行最末端调用。

定时器(Event Loop) - 图1

  1. 在 Node v11 以前,按照事件循环规则,打印顺序是 timer1 —> timer2 —> promise1 —> promise2

    在 Node 环境 microTask 会在每个阶段完成之间调用,也就是每个阶段执行最后都会执行一下 microTask 队列

定时器(Event Loop) - 图2

  1. 在 Node v11 以后,修改了事件循环规则,打印顺序是 timer1 —> promise1 —> timer2 —> promise2

    在同一个阶段中只要执行了 macrotask 就会立即执行 microtask 队列,与浏览器表现一致。

与 Node v11 之前不同的是,当 Timeout cb1 执行完时,会判断 microTask queue 是否为空。
如果不为空,会进入下一轮循环,先执行 microTask queue 的任务。所以打印 timeout1 后,打印 promise1
image.png
如何验证是进入了下一个循环tick呢?我们利用 process.nextTick会在每次循环之前触发的特点
下列代码打印顺序:timer1 —> nextTick1 —> promise1 —> timer2 —> nextTick2 —> promise2

  1. setTimeout(() => {
  2. console.log('timer1')
  3. Promise.resolve().then(() => {
  4. console.log('promise1');
  5. })
  6. process.nextTick(() => console.log('nextTick1'))
  7. })
  8. setTimeout(() => {
  9. console.log('timer2')
  10. Promise.resolve().then(() => {
  11. console.log('promise2');
  12. })
  13. process.nextTick(() => console.log('nextTick2'))
  14. })