前言

背景

工作以来后端的主力语言一直都是 Node.js,前端的语言自不必说。所以 JavaScript 这门语言对我来说的重要性不言而喻。而最近又在积极准备面试,无论对于使用还是对于面试 Node.js 相关岗位来说,事件循环是绝对绕不过去的坎。我这些年曾经多次尝试把事件循环的机制理清楚,但是限于能力水平和思维模式,一直没有完成这个愿望。

环境

  • Node.js: 14.x
  • Chrome: 81.x

目标

希望通过写这篇文章,能够分别从浏览器环境和操作系统环境彻底理清楚事件循环“我是谁”、“从哪儿来”和“到哪儿去”的三大哲学问题。

也希望读到这篇文章的朋友,如果是为了面试速成的,能够作为参考,有所帮助,能够很顺利的回答出下面的问题;如果是在工作中确实在这方面碰到问题的,通过阅读这篇文章能理清事件循环的脉络,从而协助解决问题。

典型面试问题

// 问题 1
setTimeout(() => console.log(4))

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

console.log(2)
// 问题 2
setTimeout(() => console.log(4))

new Promise(resolve => {
  resolve()
  console.log(1)
}).then(() => {
  console.log(3)
  Promise.resolve().then(() => {
    console.log('before timeout')
  }).then(() => {
    Promise.resolve().then(() => {
      console.log('also before timeout')
    })
  })
})

console.log(2)

面试版本

单线程

讲事件循环之前,需要明确一个前提,即 JavaScript 这门语言本身来说是单线程的。为什么是单线程的原因也很简单,因为浏览器的窗口内容被多个线程改来改去内容就乱了,业务逻辑也变得无法控制。但单线程会导致一个问题,即一旦碰到了诸如 IO 之类的操作就会卡住,浏览器无法响应用户的其他操作,Node.js 服务器无法处理新来的网络请求。因此,在这种情况下,就需要事件循环来解决问题。

事件循环的定义

先说说我对事件循环的定义,事件循环是为了解决 JavaScript 因为单线程且可能因为操作 IO 等比较耗时的任务卡住而引入的一种将 IO、定时操作等任务从主程序中分离,并且在执行完成以后能够通知主程序从中断的地方继续执行的一种机制。

要点:

  1. 事件循环是一种机制;
  2. 解决了单线程会因为耗时的操作而卡住的问题;
  3. IO、定时操作等任务从主程序中分离单独执行,而主程序继续往下执行;
  4. 分离出去的任务执行完成后通知主程序,主程序从分离任务的那个中断点开始恢复执行;

任务类型

从上面的要点可以发现,事件循环的核心就是将耗时的操作单独分离了出来,执行完成以后再通知主程序。从这点可以得出,程序里的任务有两种,一种是按照顺序往下执行的,我们称之为同步任务;而另一种是比较耗时的,一旦执行了主程序不再去等他执行完毕,而是继续往下执行,这种任务叫做异步任务。

在这里需要澄清一下,这里的耗时任务主要指的是 IO 操作、定时任务、用户点击等任务,CPU 计算虽然可能耗时,但并不是异步任务,而是同步任务。因为 IO 操作等异步任务基本不需要 CPU 去执行,而是需要 IO 设备自己搞定,因此 CPU 在这段事件还可以运行其他的代码。

具体的程序运行方式可以看一下这段视频

宏任务和微任务

除了分成同步任务和异步任务之外,异步任务还细分成宏任务和微任务。关于为什么分成宏任务和微任务,这篇文章说的很形象。

  • 宏任务:script(全局任务), setTimeout, setInterval, setImmediate, I/O, UI rendering
  • 微任务:process.nextTick, Promise, Object.observer, MutationObserver

任务队列

因为主程序碰到异步任务的时候就会执行异步任务,不等结束继续执行,碰到下一个异步任务的时候又开始执行,这样就会导致存在很多已经完成但还没有来得及从中断的位置继续执行的任务。这些任务会被运行时放置到异步任务队列中。同样的,队列也分成宏任务队列和微任务队列。

运行时的一般原则是:

  1. 先执行一个宏任务执行同步代码;
  2. 执行完成后,会去微任务队列里面检查,如果存在已经完成的异步任务,则恢复该异步任务继续执行。该微任务执行完成后再去检查微任务队列,有任务则再继续执行,也就是说,如果微任务一直存在,则一直执行,知道微任务队列为空;
  3. 去检查宏任务队列,如果有完成的任务,跳到第一条;

关于任务队列和执行机制的更详细的说明可以参考这篇文章

到这里我们可以看到如果存在很多完成的 Promise,即使 setTimeout 的时间到了也不会执行,直到这些 Promise 都执行完毕。

现在我们就可以回答一开始那两段程序的输出了。

问题 1: 1 2 3 4
问题 2: 1 2 3 before timeout also before timeout 4

然后再留一道送命题。

console.log('start')
setTimeout(() => {
    console.log('children2')
    Promise.resolve().then(() => {console.log('children2-1')})
}, 0)
setTimeout(() => {
    console.log('children3')
    Promise.resolve().then(() => {console.log('children3-1')})
}, 0)
Promise.resolve().then(() => {console.log('children1')})
console.log('end') 

不同运行时下的实现方式

Node.js

以上所讲述的,都是按照 Chrome 的执行标准,Node.js 在执行方式上稍有不同,体现在:

nodejs-event-loop

浏览器 Chrome

深入版本

我们在突击了面试版本以后,是否还有一些问题仍然没有得到解答,比如说:

  • 如果没有事件循环,世界是什么样子,难道没有事件循环就不编程了吗?
  • 运行时是怎样做到事件循环的,浏览器和 Node.js 的实现方式一样吗?
  • 事件循环是否尤其局限?什么局限?
  • 除了 JavaScript,其他领域是否也使用了这个机制?

操作系统机制

libuv

Node.js 的封装

问题

  • 程序的执行是一个栈,如果之前执行的一个异步任务此时执行完毕,但是此时程序已经执行过好多其他的方法了,那又怎么恢复到之前的程序栈呢?
  • 异步任务队列有几个?

参考资料