先说明,本文针对的是node.js运行时,由uv实现的event loop

所有理论依据来源于 node.js源码。(版本略)

0x00 总有面试官要刁难朕

我们不妨看一下这样的题目

console.log(1)

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

Promise.resolve().then(() => {
	console.log(3)
}).then(() => {
	console.log(4)
})

console.log(5)

请问上面代码的打印结果?
▇▇▇▇▇▇▇▇▇▇  <--- 刮开查看答案

对吧,无数次被这种装X面试题恶心。

小声哔哔:谁项目里会这样写代码?

不过恶心归恶心,不管有没有实用性,透过这些题目来弄清楚技术的真相,是没有坏处的。

我们的目标是:以后还有类似的题目,不管千变万化,直接通关。

0x01 没有银弹,还是要拿源码说话

为了证明不是胡说八道,先贴出关键源码。

// 来自 deps/uv/src/unix/core.c
while (r != 0 && loop->stop_flag == 0) {
    uv__update_time(loop); 
    uv__run_timers(loop); // ⭐️ timer
    ran_pending = uv__run_pending(loop); // ⭐️ 上一个循环一些没来得及做完的事
    uv__run_idle(loop); // ⭐️ 底层用,暂时不懂
    uv__run_prepare(loop); // ⭐️ 底层用,暂时不懂

	/*
	* 忽略几行不重要的
    */
    
    uv__io_poll(loop, timeout); // ⭐️io, network or file system 等等
    uv__run_check(loop); // ⭐️ setImmediate
    uv__run_closing_handles(loop); // ⭐️ event on('close')

    if (mode == UV_RUN_ONCE) {
     // 这里不重要
    }

    r = uv__loop_alive(loop);
    if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
     // 这里不重要
  }

然后我们开始逐个去了解。

timer

这部分主要是检查有没有可以执行的定时器,包括但不限于setTimeout``setInterval

这里的的具体实现在deps/uv/src/unix/timer.c,简单说就是使用一个最小堆(小顶堆), 把时间最接近的一个取出来,判断当前时间是否可以执行。

pending

这个阶段是执行 上一个循环poll阶段还没来得及处理的callback。

这句话,在下面介绍poll阶段的时候才回过头来理解。

idle + prepare

按文档说是底层预留的,暂时我还没研究清楚。请忽略。

poll

关键!这个阶段处理的,就是我们比较熟悉的network , fs之类的异步操作回调。就是说你去请求一个远程的接口,那么回调函数会在poll阶段执行。

然后就是跟上面pending的关联。

由于uv__io_poll代码有点长就不贴了,有兴趣自己去看。

一般来说,我们的每一个阶段,都会处理完已经就绪的所有callback,如果poll阶段触发大量的 callback,就会占用很多的时间。

我们的uv当然是不会设计成这样的,所以,它会从timer里拿到最小的(未来最快到达的)一个定时器的时间,作为poll阶段的 timeout

如果timeout到了,还有callback没开始执行的,对不起,请到pending队列里。

可能是uv认为,poll阶段的callback,相对来说对“准时”不太敏感,所以通过这样尽量确保timer的执行不会误差太多。

check

为什么叫做check我也不清楚。

但是这个阶段将会运行我们 setImmediate注册的回调。

很震惊吧,setImmediate完全就不是timer那一族的~~~~

closing_handles

执行close事件注册的回调,放在循环的最后一个阶段,也是合情合理。

0x03 那么我们练习一下

关于process.nextTick
nextTick 是个复杂的实现,需要另外开一篇来讲解。
为了方便下面的练习,我暂时先把结论放出来。
nextTick会直接追加在每一个阶段末尾,就是说,如果timer阶段的回调里有process.nextTick,通过这个来注册的回调,会在紧接着的pending之前就执行。

✏️ 题目一

setTimeout(() => {
  console.log('A')
}, 0)

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

答案:

AB 或 BA

解释:

首先这里的第一个知识点,是timer的第二个参数,取值范围是 [1, 2^31 - 1]。也就是说,这个 0 会被当成 1 处理。
然后根据运行环境的差异,如果进入到当前循环前,已经过去了 1ms ,那就打印 AB。
否则,如果在 1ms 内就开始了本次循环,那timer还没准备后,就会在下一次循环触发,自然就打印 BA。

✏️ 题目二

const fs = require('fs')

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('A')
  }, 0)

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

答案:

BA 

解释

知识点在于fs.readFile,这个是 io操作,它的整个回调会在poll阶段执行。
poll之后马上进入check,所以正好先执行了刚注册的setImmediate
setTimeout自然就要等到下一个循环的timer阶段。

✏️ 题目三,这个划重点

setImmediate(() => {
  console.log('1')
  setImmediate(() => {
    console.log('2')
  })
  process.nextTick(() => {
    console.log('nextTick')
  })
})

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

答案:

1 3 nextTick 2

解释:

首先,最外层的两个setImmediate会顺序注册到同一个check阶段,而上面提到nextTick会直接追加到当前阶段末尾,所以是1 3 nextTick而不是1 nextTick 3
而内层的setImmediate会注册到下一次循环的check阶段,所以 2最后打印。
请细品。

0x04 继续练习之前,讲讲 promise

process.nextTick类似,promise的回调也是在当前阶段的末尾追加。

不过有意思的是,process.nextTick拥有更高的优先级。

这个实现细节,也是需要另外一篇文章来讲解(挖坑+1)。。。。

0x05 继续练习吧

✏️ 题目四

const promise = Promise.resolve()

promise.then(() => {
  console.log('A')
})

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

答案:

BA

解释

无需解释,先记住二者的优先级。

✏️ 题目五

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)

答案:

2 3 5 4 1

解释

这里有个知识点,new Promise的参数是同步执行的。
所以 2 3 5都是同步顺序输出的。
然后 then 在一个同步的for循环后触发,会追加到本阶段末尾,所以4紧接着输出。
最后是setTimeout,会在下一个循环的timer阶段执行,输出 1

🐸 BOSS戦

setImmediate(() => {
  console.log(1)
  setTimeout(() => {
    console.log(2)
  }, 100)
  setImmediate(() => {
    console.log(3)
  })
  process.nextTick(() => {
    console.log(4)
  })
})
process.nextTick(() => {
  console.log(5)
  setTimeout(() => {
    console.log(6)
  }, 100)
  setImmediate(() => {
    console.log(7)
  })
  process.nextTick(() => {
    console.log(8)
  })
})
console.log(9)

答案:

9 5 8 1 7 4 3 6 2

解释:

你已经是一个成熟的程序员了,试着用上面的知识自己来解释吧。
Tips 可以尝试画出来,一共经过了多少个循环, 每个循环的每个阶段执行了什么。