Event Loop,浏览器环境和 Node 环境下的不同

开头总是不知道写些什么东西……

为什么要有事件循环

JavaScript 被设计的初愿是执行在浏览器上可以动态加载的脚本,以便进行 DOM 节点的操作,所以,如果它是像 Java 一样的多线程语言,那么对 DOM 的操作就是未知且不可预测的了。

所以:

JavaScript 是一门单线程语言。

单线程就意味着有丶事情做起来没那么理想化,比如读取一个文件的事件,你以为它读完了文件内容而进行下一步的操作,然而运行的时候却发现,并没有这个结果存在,最后才发现,oh!!原来这个操作是异步的。。对,没错,单线程由此引出了 同步异步 的问题。只有将某些操作(如读取文件),作为异步执行,才能避免 阻塞 ,使脚本执行时间更迅速,用户体验更好。

所谓的事件循环,就是 JavaScript 执行异步操作的过程

同步与异步,阻塞与非阻塞

有段时间,我认为 异步非阻塞 就是同一样事。其实不然。。

  • 阻塞,代码从上之下依次执行,后续的代码必须等待前面的代码执行完毕
  • 非阻塞,代码从上至下依次执行,后续代码的执行不需要等待前面代码执行完毕
  • 同步,主动等待前面代码执行完毕
  • 异步,通过轮询监听异步操作是否执行完毕

也就是说:

  • 阻塞和非阻塞指的是调用者的状态,关注的是程序在等待调用结果时的状态
  • 同步和异步指的是被调用者是如何通知的,关注的是消息通知机制

通俗点讲,阻塞和非阻塞是指代码执行的顺序,同步和异步是指在等待消息的形式

好吧,举个粗鄙点的例子解释一下:

我和室友明天要交作业了,但我俩都没做,然后借了份已经做了的作业回来抄。秉着友好相处的理念,我让室友先抄,然后这时候就出现了 同步异步阻塞非阻塞 的问题了

  • 室友在抄作业,我在他桌上啥也不干,看着他抄完我再抄,这是 阻塞同步
  • 室友在抄作业,我在他桌上啥也不干,他抄完了会告诉我,这是 阻塞异步
  • 室友在抄作业,我在我桌上看剧,一段时间看一次他抄完没,这是 非阻塞同步
  • 室友在抄作业,我在我桌上看剧,他抄完了会告诉我,这是 非阻塞异步

浏览器下的 Event Loop 的顺序

借图:
web

首先讲一下异步任务有分宏任务(macro-task)和微任务(micro-task):
macro-task包括: setTimeout, setInterval, setImmediate, I/O, UI rendering。
micro-task包括:process.nextTick, Promise, Object.observe, MutationObserver。

process.nextTick 的执行优先级大于 Promise

JavaScript 执行的时候,会产生一个执行栈,其次,还会有一个任务队列,这个队列就是用来存放等待执行的异步操作的回调。。

emmm,有点绕,是这样的,当执行栈中发现有异步操作,会交给 Web APIs 去执行,并将执行结果放在队列中,当执行栈中主线程也就是同步代码执行完毕,就先将此时的 micro-task 执行,然后会把任务队列中的一个 macro-task 入栈出栈,并把存在的 micro-task 都执行,执行完之后,再将下一个 macro-task 入栈出栈,执行此时的 micro-task ,如此反复………………便是 Event loop

官方点的说法就是:

  • 执行完主执行线程中的任务。
  • 取出micro-task中任务执行直到清空。
  • 取出macro-task中一个任务执行。
  • 取出micro-task中任务执行直到清空。
  • 重复3和4。

Node 下的 Event Loop 的顺序

node

这是 Node 官方文档中给出的 Node 事件循环的流程。可以一眼看出的是,microTasks 操作在每个阶段都执行一遍。先解释一下几个关键的环节:

  • timers:这个环节里面执行的是和定时器相关的函数,如 setTimeout
  • I/O callbacks:这个环节里面执行的是和 IO 读写相关的操作,如 fs.readFile
  • poll:检查上面两步是否已经完成
  • check:执行 Node 特有的 setImmediate

所以是:

1
2
3
4
microTasks => timer => microTasks => I/O => microTasks => check
process.nextTick/Promise => setTimeout => process.nextTick/Promise =>
fs.readFile => process.nextTick/Promise => setImmediate

在每个阶段开始前,会清空一次 microTasks 中的任务,注意!

是在每个阶段进行前,会清空一次 microTasks 中的任务

  • 这就是浏览器和 Node 事件循环的不同

对比

看一段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const sleep = (time) => {
let startTime = new Date()
while (new Date() - startTime < time) { }
console.log('1s over')
}
setTimeout(() => {
console.log('setTimeout - 1')
setTimeout(() => {
console.log('setTimeout - 1 - 1')
sleep(1000)
})
Promise.resolve().then(() => {
console.log('setTimeout - 1 - then')
Promise.resolve().then(() => {
console.log('setTimeout - 1 - then - then')
})
})
sleep(1000)
})
setTimeout(() => {
console.log('setTimeout - 2')
setTimeout(() => {
console.log('setTimeout - 2 - 1')
sleep(1000)
})
Promise.resolve().then(() => {
console.log('setTimeout - 2 - then')
Promise.resolve().then(() => {
console.log('setTimeout - 2 - then - then')
})
})
sleep(1000)
})

浏览器下执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
setTimeout - 1
1s over
setTimeout - 1 - then
setTimeout - 1 - then - then
setTimeout - 2
1s over
setTimeout - 2 - then
setTimeout - 2 - then - then
setTimeout - 1 - 1
1s over
setTimeout - 2 - 1
1s over

Node 下执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
setTimeout - 1
1s over
setTimeout - 2
1s over
setTimeout - 1 - then
setTimeout - 2 - then
setTimeout - 1 - then - then
setTimeout - 2 - then - then
setTimeout - 1 - 1
1s over
setTimeout - 2 - 1
1s over

仔细对比一下有什么不同

总结

  • 在浏览器中,每执行一个 macro-task,就会将当前的 micro-task 清空
  • 在 Node 中,每一个阶段开始前,就会将当前的 micro-task 清空,这个阶段可能不止一个 macro-task