事件循环专题 —— Event-Loop

从一道面试题说起

console.log(1)

setTimeout(function() {
  console.log(2)
})

new Promise(function (resolve) {
  console.log(3)
  resolve()
 }).then(function () {
  console.log(4)
}).then(function() {
  console.log(5)
})

console.log(6)
复制代码

大家先调动自己现有的知识思考一下:上述代码的输出结果是什么?

答案是:1、3、6、4、5、2

如果你能够准确给出上面的回答、并且说出你的依据,那么恭喜你——你的事件循环基础很扎实,可以直接跳至真题部分开始刷题了;如果你的答案和上面不一致,也不要着急,这个输出顺序是由浏览器的事件循环规则决定的。我们接下来就拿它开刀

浏览器中的 Event-Loop 机制解析

关键角色剖析

在浏览器的事件循环中,首先大家要认清楚 3 个角色:函数调用栈、宏任务(macro-task)队列和微任务(micro-task)队列

函数调用栈(在《执行上下文与调用栈》讲过了):当引擎第一次遇到 JS 代码时,会产生一个全局执行上下文并压入调用栈。后面每遇到一个函数调用,就会往栈中压入一个新的函数上下文。JS引擎会执行栈顶的函数,执行完毕后,弹出对应的上下文。

1598596381.jpg

一句话:如果你有一坨需要被执行的逻辑,它首先需要被推入函数调用栈,后续才能被执行。函数调用栈是个干活的地方,它会真刀真枪地给你执行任务

那么宏任务队列、微任务队列又是啥呢?

各位知道,JS 的特性就是单线程 + 异步。在JS中,咱们有一些任务,比如说上面咱们塞进 setTimeout 里那个任务,再比如说你在 Promise 里面塞进 then 里面那个任务——这些任务是异步的,它们不需要立刻被执行,所以它们在刚刚被派发的时候,并不具备进入调用栈的“资格”

这暂时没资格咋整呢?

排队等呗!

于是这些待执行的任务,按照一定的规则,乖乖排起长队,等待着被推入调用栈的时刻到来——这个队列,就叫做 任务队列

所谓“宏任务”与“微任务”,是对任务的进一步细分。具体的划分依据如图所示:

1598596381.jpg

1598596381.jpg

常见的 macro-task 比如: setTimeout、setInterval、 setImmediate、 script(整体代码)、I/O 操作等

常见的 micro-task 比如: process.nextTick、Promise、MutationObserver 等

注意:script(整体代码)它也是一个宏任务;此外,宏任务中的 setImmediate、微任务中的 process.nextTick 这些都是 Node 独有的。

循环过程解读

基于对 micro 和 macro 的认知,我们来走一遍完整的事件循环过程。

一个完整的 Event Loop 过程,可以概括为以下阶段:

  1. 执行并出队一个 macro-task。注意如果是初始状态:调用栈空。micro 队列空,macro 队列里有且只有一个 script 脚本(整体代码)。这时首先执行并出队的就是 script 脚本;
  2. 全局上下文(script 标签)被推入调用栈,同步代码执行。在执行的过程中,通过对一些接口的调用,可以产生新的 macro-task 与 micro-task,它们会分别被推入各自的任务队列里。这个过程本质上是队列的 macro-task 的执行和出队的过程;
  3. 上一步我们出队的是一个 macro-task,这一步我们处理的是 micro-task。但需要注意的是:当 macro-task 出队时,任务是一个一个执行的;而 micro-task 出队时,任务是一队一队执行的。因此,我们处理 micro 队列这一步,会逐个执行队列中的任务并把它出队,直到队列被清空;
  4. 执行渲染操作,更新界面;
  5. 检查是否存在 Web worker 任务,如果有,则对其进行处理。

这里我给大家列出的5步,是相对完整的过程。其实,针对面试,咱们关注第1-3步就足够了。第4步第5步,面试时说了没错,不说也没人会难为你,不必较劲。

真题重做,逐行分析

现在咱们基于对这个过程的理解,重新做一遍开篇那道题:

console.log(1)

setTimeout(function() {
  console.log(2)
})

new Promise(function (resolve) {
  console.log(3)
  resolve()
 }).then(function () {
  console.log(4)
}).then(function() {
  console.log(5)
})

console.log(6)
复制代码

首先被推入调用栈的是全局上下文,你也可以理解为是 script 脚本作为一个宏任务进入了调用栈,这个动作同时创建了全局上下文;与此同时,宏任务队列被清空,微任务队列暂时还是空的

1598596381.jpg

全局代码开始执行,跑通了第一个console:

console.log(1)
复制代码

此时输出1
接下来,执行到 setTimeout 这句,一个宏任务被派发了,宏任务队列里多了一个小兄弟

1598596381.jpg

再往下走,遇到了一个 new Promise。大家知道,Promise 构造函数中函数体的代码都是立即执行的,所以这部分逻辑执行了

console.log(3)
resolve()
复制代码

第一步输出了3,第二步敲定了 Promise 的状态为 Fullfilled,成功把 then 方法中对应的两个任务依次推入了微任务队列

1598596381.jpg

再往下走,就走到了全局代码的最后一句

console.log(6)
复制代码

这一步输出了6,script脚本中的同步代码就执行完了。不过大家注意,全局上下文并不会因此消失——它与页面本身共存亡。接下来,咱们就开始往调用栈里推异步任务了。本着 “一个 macro,一队micro” 的原则,咱们现在需要处理的是微任务队列里的所有任务

1598596381.jpg

首先登场的是 then 中注册的第一个回调,这个回调会输出4

function () {
  console.log(4)
}
复制代码

接着处理第二个回调

1598596381.jpg
这个回调会输出5

function () {
  console.log(5)
}
复制代码

如此一来,微任务队列就被清空了

1598596381.jpg

我们重新把目光放在宏任务队列上,将其队列头部的一个任务入栈

1598596381.jpg

对应的回调执行,输出2

function() {
  console.log(2)
}
复制代码

执行完毕后,我们就结束了所有任务的处理,两个任务队列都空掉了

1598596381.jpg

此时,只剩下一个全局上下文,待你关闭标签页后,它也会跟着被销毁。

Node中的Event-Loop与浏览器有何不同?

这是一道大厂面试官普遍钟爱的面试题。要想答出 Node 中的 Event-Loop 和浏览器有啥区别,首先你得能说清楚,Node 中的 Event-Loop 本身是怎么一回事。

Node技术架构分析-认识 libuv

这里我为大家画了一张简化的 Node 架构图

1598596381.jpg

Node整体上由这三部分组成:

  • 应用层:这一层就是大家最熟悉的 Node.js 代码,包括 Node 应用以及一些标准库。
  • 桥接层:Node 底层是用 C++ 来实现的。桥接层负责封装底层依赖的 C++ 模块的能力,将其简化为 API 向应用层提供服务。
  • 底层依赖:这里就是最最底层的 C++ 库了,支撑 Node 运行的最基本能力在此汇聚。其中需要特别引起大家注意的就是 V8 和 libuv:
    • V8 是 JS 的运行引擎,它负责把 JavaScript 代码转换成 C++,然后去跑这层 C++ 代码。
    • libuv:它对跨平台的异步I/O能力进行封装,同时也是我们本文的主角:Node 中的事件循环就是由 libuv 来初始化的。

注意哈:这里第一个区别来了——浏览器的 Event-Loop 由各个浏览器自己实现;而 Node 的 Event-Loop 由 libuv 来实现。

libuv中的 Event-Loop 实现

libuv 主导循环机制共有六个循环阶段。这里我引用 Node 官方(出处:nodejs.org/zh-cn/docs/…

1598596381.jpg

注:Node 官方给出的这张图非常值得参考,不过不建议大家直接通过阅读其官方文档来理解事件循环,一些表达还是会相对比较生涩,打击积极性。

我们先来瞅瞅这六个阶段各是处理什么任务的:

  • timers阶段:执行 setTimeoutsetInterval 中定义的回调;
  • pending callbacks:直译过来是“被挂起的回调”,如果网络I/O或者文件I/O的过程中出现了错误,就会在这个阶段处理错误的回调(比较少见,可以略过);
  • idle, prepare:仅系统内部使用。这个阶段我们开发者不需要操心。(可以略过);
  • poll (轮询阶段):重点阶段,这个阶段会执行I/O回调,同时还会检查定时器是否到期
  • check(检查阶段):处理 setImmediate 中定义的回调
  • close callbacks:处理一些“关闭”的回调,比如socket.on('close', ...)就会在这个阶段被触发。

宏任务与微任务

和浏览器中一样,Node 世界里也有宏任务与微任务之分。划分依据与我们上文描述的其实是一致的

  • 常见的 macro-task 比如: setTimeout、setInterval、 setImmediate、 script(整体代码)、I/O 操作、UI 渲染等。
  • 常见的 micro-task 比如: process.nextTick、Promise、MutationObserver 等

需要注意的是,setImmediate 和 process.nextTick 是 Node 独有的。

一起走一遍 Node 中的事件循环流程

在这六个阶段中,大家需要重点关注的就是timers、poll 和 check这三个阶段,相关的命题也基本上是围绕它们来做文章。不过在进行考点点拨之前,我们还是要把整个循环的流程给走一遍

  1. 执行全局的 Script 代码(与浏览器无差);
  2. 把微任务队列清空:注意,Node 清空微任务队列的手法比较特别。在浏览器中,我们只有一个微任务队列需要接受处理;但在 Node 中,有两类微任务队列:next-tick 队列和其它队列。其中这个 next-tick 队列,专门用来收敛 process.nextTick 派发的异步任务。在清空队列时,优先清空 next-tick 队列中的任务,随后才会清空其它微任务
  3. 开始执行 macro-task(宏任务)。注意,Node 执行宏任务的方式与浏览器不同:在浏览器中,我们每次出队并执行一个宏任务;而在 Node 中,我们每次会尝试清空当前阶段对应宏任务队列里的所有任务(除非达到了系统限制);
  4. 步骤3开始,会进入 3 -> 2 -> 3 -> 2…的循环(整体过程如下所示)
macro-task-queue ----> timers-queue 
                            |
                            |
micro-task-queue ----> pending-queue
                            |
                            |
micro-task-queue ---->  polling-queue
                            |
                            |
micro-task-queue ---->  check-queue
                            |
                            |
micro-task-queue ---->  close-queue
                            |
                            |
macro-task-queue ----> timers-queue 

......
复制代码

整体来看,Node 中每次执行异步任务都是以批量的形式,“一队一队”地执行。循环形式为:宏任务队列 -> 微任务队列 -> 宏任务队列 —> 微任务队列… 这样交替进行。

经过咱们上面这一通讲,相信把 Node 和浏览器之间不同的 Event-Loop 机制捋清楚,对现在的你来说已经不是什么难事了。

不过,可别高兴得太早。事件循环这块,比起问答题,更常见的是编码阅读题。咱们下面就一起来通过一系列的真题巩固一下认知。

Node事件循环命题思路剖析

Node 事件循环的命题频率并没有浏览器那么高,但每次都能难到一大票人

不过不要怕,大家结合上面咱们的分析、归纳出自己的一套对 Node、浏览器两套事件循环机制的理解;再掌握下面的三个热门命题点,相信你面对 Event-Loop 是不虚的

nextTick 和 Promise.then 的关系

在做这题之前,大家首先要在心里默念一遍 Node 中 micro-queue 的这个特征

Node 清空微任务队列的手法比较特别。在浏览器中,我们只有一个微任务队列需要接受处理;但在 Node 中,有两类微任务队列:next-tick 队列和其它队列。其中这个 next-tick 队列,专门用来收敛 process.nextTick 派发的异步任务。在清空队列时,优先清空 next-tick 队列中的任务,随后才会清空其它微任务。

记住:在 Node 异步队列真题中,只要见到 process.nextTick 这货,它肯定不是善茬,多半是要和 Promise.then 一起出来唬人的

Promise.resolve().then(function() {
  console.log("promise1")
}).then(function() {
  console.log("promise2")
});

process.nextTick(() => {
 console.log('nextTick1')
 process.nextTick(() => {
   console.log('nextTick2')
   process.nextTick(() => {
     console.log('nextTick3')
     process.nextTick(() => {
       console.log('nextTick4')
     })
   })
 })
})
复制代码

问:上述代码的输出结果是多少?

思考:我们现在已经知道,不管你整什么微任务过来,只要它不是 process.nextTick 派发的,全部都要排队在 process.nextTick 后面执行。因此输出顺序是

nextTick1
nextTick2
nextTick3
nextTick4
promise1
promise2
复制代码

setTimeout 和 setImmediate 的故事

setImmediate 是啥?

开始做题之前,先给大家简单介绍一些 setImmediate,它的调用形式有以下两种

var immediateID = setImmediate(func, [ param1, param2, ...]);
var immediateID = setImmediate(func);
复制代码

注意,setImmediate 虽然和 setTimeout 类似(它们都用于延迟某个操作),setImmediate 可倔多了,它不接受你给它指定执行的时机(没有延时时间作为入参),它只认一个执行时机——离它最近的那一次 check

这也和 setImmediate 本身的用意是分不开的

setImmediate()方法用于中断长时间运行的操作,并在完成其他操作(如事件和显示更新)后立即运行回调函数。

了解了 setImmediate 的用法和特性,我们一起来看 Node 事件循环面试题中最“诡异”的一道

setTimeout(function() {
    console.log('老铁,我是被 setTimeout 派发的')
}, 0)

setImmediate(function() {
  console.log('老铁,我是被 setImmediate 派发的')
})
复制代码

问:上述代码的执行结果是啥?

答:不一定!!!

没错,就是不一定。这里我建议大家把这个 demo 拷贝下来,丢进自己的 Node.js 文件里去跑一跑。多跑几次,你就会发现如下的神奇规律

1598596381.jpg

没错!正如图中所示一样,这个例子里 setImmediate 和 setTimeout 谁先执行是个谜啊老铁们——它是随机的!

为啥会这样呢?(注意接下来我们会对这个现象做一个原因分析,这个原因分析至少有一半分,各位坚持住哈)

  • 首先,给各位普及一个小知识:setTimeout 这个函数的第二个入参,它的取值范围是 [4, 2^31-1]。也就是说,它是不认识 0 这个入参的。不认识咋整呢?强行给你4掉!也就是说下面这种写法
setTimeout(function() {
    console.log('老铁,我是被 setTimeout 派发的')
}, 0)
复制代码

其实等价于

setTimeout(function() {
    console.log('老铁,我是被 setTimeout 派发的')
}, 4)
复制代码

也就是说这个回调,其实被延迟了 4ms。

然后,各位需要认识到这样一个问题:事件循环的初始化,是需要时间的。

怎么理解这个“需要时间”呢?这意味着初始化事件循环的时间,可能大于 4ms,也可能小于 4ms,这就会带来下面两种可能性

  • 当初始化时间小于 4ms 时:进入了 timers 阶段,却发现 setTimeout 定时器还没到时间,于是往下走。走到 check 阶段,执行了 setImmediate 回调;在后面的循环周期里,才会执行 setTimeout 回调;
  • 当初始化时间大于 4ms 时:进入了 timers 阶段,发现 setTimeout 定时器已经到时间了,直接执行 setTimeout 回调;结束 timers 阶段后,走啊走,走到了 check 阶段,顺理成章地又执行了 setImmediate 回调。

结合咱们上面的分析,相信各位已经认识到:其实每一种输出结果都是对的、都是符合我们事件循环原则的。顺序上的差别是由我们不可控的“事件循环初始化时间”导致的。因此这个“不一定”,咱们到时候一定是回答得理直气壮。

poll阶段对定时器的处理

前面已经提到过,poll 阶段是一个重点阶段,大部分的回调任务都会在这个阶段被处理。重点阶段重点分析,咱们现在就来扒一扒 poll 阶段具体有哪些情况。

进入 poll 阶段时,我们考虑以下两种场景:

  1. poll 队列不为空。这种情况好办,直接逐个执行队列内的回调并出队、直到队列被清空(或者到达系统上限)为止;
  2. poll 队列本来就是空的。没活干,事件循环也不能闲着:它首先会检查有没有待执行的 setImmediate 任务,如果有,则往下走、进入到 check 阶段开始处理 setImmediate;如果没有 setImmediate 任务,那么再去检查一下有没有到期的 setTimeout 任务需要处理,若有,则跳转到 timers 阶段。

那如果连 setTimeout 任务也没有呢?那咱 poll 阶段也不钻这个牛角尖了——没活干就算了,我等着!此时 poll 阶段会进入到等待状态,等待回调任务的到来。一旦有回调进入,poll 就会“立刻出击”。

结合这一连串的分析,各位需要记住这样一个结论——在 poll 阶段处理的回调中,如果既派发了 setImmediate、又派发了 setTimeout,那么这个顺序是板上钉钉的——一定是先执行 setImmediate,再执行 setTimeout。

接下来我们一起来看一道真题
首先给出如下的目录结构:

1598596381.jpg

  • test.js 文件中是任意代码
  • index.js 文件中,写入下面内容
const fs = require('fs')
const path = require('path')
const filePath = path.join(__dirname, 'test.js')

console.log(filePath)   

// -- 异步读取文件
fs.readFile(filePath,'utf8',function(err,data){
    setTimeout(function() {
      console.log('老铁,我是被 setTimeout 派发的')
    }, 0)

    setImmediate(function() {
      console.log('老铁,我是被 setImmediate 派发的')
    })
});
复制代码

问:输出顺序如何?为什么?

解答:经过我们前面的分析,这道题的答案是毫无疑问的:先输出 setImmediate 中的 console,然后再输出 setTimeout 中的 console。

分析:各位首先要注意到——readFile这个接口,是一个异步的文件I/O接口。在 Node 中,I/O 回调是在 poll 阶段处理的。因此,当 readFile 回调被执行时,实际上走到了这个阶段
1598596381.jpg

在 poll 阶段,派发出去两个任务:一个是在 check 阶段被处理的 setImmediate 回调,一个是在 timers 阶段被处理的 setTimeout 回调。

结合上图以及我们前面的分析,可以很明显地看出:check 阶段永远比 timers 阶段离 poll 更近,因此 setImmediate 总是比 setTimeout 先执行。

所以说大家记住这个结论:在 poll 阶段处理的回调中,如果既派发了 setImmediate、又派发了 setTimeout,那么这个顺序是板上钉钉的——一定是先执行 setImmediate,再执行 setTimeout。

注意!!!Node11事件循环已与浏览器事件循环机制趋同!

这是个大坑啊!

一道阴险的考题

我们一起来看这样一道题

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


setTimeout(() => {
  console.log('timeout2');
  Promise.resolve().then(function() {
    console.log('promise1');
  });
}, 0);

setTimeout(() => {
  console.log('timeout3')
}, 0)
复制代码

问:上述代码在浏览器、Node中的执行结果各是什么?
这题很险,但各位只要记住一句话:Node11开始,Node的事件循环已经和浏览器趋同。注意是“趋同”而不是一毛一样。其中最明显的改变是

Node11开始,timers 阶段的setTimeout、setInterval等函数派发的任务、包括 setImmediate 派发的任务,都被修改为:一旦执行完当前阶段的一个任务,就立刻执行微任务队列。

这就意味着,上面这道题,在浏览器和在 Node11 中跑出来的结果一毛一样——不信各位切换到高版本跑跑看

我这里可以给大家看一下我用 Node9.3.0 和用 Node12.4.1 分别跑上面demo代码的结果

以下是 v9.3.0 版本下的执行结果

1598596381.jpg

我们看到在 timers 阶段,依次执行了所有的 setTimeout 回调、清空了队列——这符合我们前面对 Node 事件循环机制的描述。

以下是 v12.4.1 版本下的执行结果

1598596381.jpg

同时我们再看一下浏览器跑上面代码的结果

1598596381.jpg

很明显,Node11及以上的版本,对这段代码的执行结果和浏览器一毛一样。

明确了高低版本间的区别,这里需要大家采取的对策是:只要遇到 Node 事件循环相关的编码类题目,都在答题结束后补充上咱们前面对 Node11 版本的这部分讲解。让面试官知道,你不是一个死背教条、一万年不更新一次知识库的憨憨;而是一个紧跟时代变化,头脑灵活的好程序员。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享