【知识梳理】宏任务/微任务的个人理解

2,894 阅读12分钟

背景


​  大概是大半年前,我在微信上看到了一篇前端文章。文章开头就拿了一道面试题,让读者写出答案。题目乍看上去不难,都是console.log()的代码,唯一让我觉得有猫腻的是多了一些setTimeout和Promise。我也没想太多,信心满满地就开始做题了。果不其然,和正确答案南辕北辙。看作者后续的解释,才第一次接触到了宏任务与微任务这两个概念。当时理解了一点,知道这两个概念都是用于区分异步任务的。随后就过了大半年,直到前几天又看了一道题。答案差了一半,脑海里对于宏任务和微任务的理解又变得模糊了。反思了一下,还是当初对于知识的理解就不深刻,没有化为自己的东西。这也就是基础不扎实的一个表现吧。

​  在掘金上看了不少同行的文章,但都不是自己的理解,在此总结一下自己的理解。

JS的特征:单线程


​  在说宏任务和微任务之前,先说一下一些基础的概念。JavaScript是1995年诞生的,最初的目的是赋予网页生命,在网页上执行一些逻辑,增加用户与网页的交互,让网页变得更加有「智能」。因为最初的目的很简单,所以注定了JS并不复杂,也不允许很复杂。在这个基础上,JS的一个特质就凸显出来了,那就是单线程

​  单线程是什么意思呢?意思就是JS引擎一次只能处理一件事情,如果有多件事情需要处理,那么JS引擎会一件一件的来。我们可以认为JS引擎就像郭靖一样,憨憨的,学不会周伯通的左右互搏。既然有单线程语言,那肯定也有多线程语言了。没错,基本上后端语言都是多线程的,就像黄蓉一样,一心多用,古灵精怪。

​  这个世界是客观的,得到了一些东西相应地也会失去一些东西。个中好坏都凭自己去判断与接受。单线程的JS带来的好处就是简单,不会出现多个线程同时修改DOM的情况,那样网页将会变得一团糟。

​  其实,JavaScript 单线程指的是浏览器中负责解释和执行 JavaScript 代码的只有一个线程,即为JS引擎线程,但是浏览器的渲染进程是提供多个线程的,如下:

  • JS引擎线程
  • 事件触发线程
  • 定时触发器线程
  • 异步http请求线程
  • GUI渲染线程

同步和异步事件


​  这里讲一下同步和异步,这两个概念是脱离编程语言的。映射的是现实世界中的情况。同步就是马上就能完成的事情,异步就是一时半会不能完成的事情。

​  举个例子,打开课本,这就是一个同步事件。你马上就能做到,没有丝毫的延迟和不确定性。再看水壶烧水,这是一个异步事件。因为你不能控制水烧开,只能等待水被烧开。对比了一下,异步事件比同步事件多了「不确定性」,或者说引入了「变量」。反映到代码上,下面的第一行代码就是同步代码,直接打印出1。第二行代码定时器 setTimeout,是一个异步事件,因为需要等待一个设置好的时间200ms才能完成打印。即使这个时间是0,也会被JS引擎归入异步事件。当然了,ajax请求更是典型的异步事件,需要等待服务器的响应。

console.log(1);
setTimeout(() => {
  console.log(2);
},200);

​  异步事件中有一个回调函数的概念,因为异步事件不是一时半会就能完成了。那么当异步事件完成之后,需要做的事情是什么呢?好比上面说的水壶烧水,水开了之后,我要喝水,这就是一个回调逻辑。也可以等水开了,我要洗衣服,这也是一个回调逻辑。所以说异步事件,需要注册一个回调函数,表示异步事件完成之后需要做的处理。那么这里隐藏了一个问题,那就是谁在异步事件完成之后通知JS引擎呢?答案是浏览器的线程,负责监听异步事件的完成。

​  最开始说了JS是单线程语言,一次只能做一件事,那么碰到同步事件就很简单了,不废话,直接做。但是遇到异步事件,就有点复杂了,JS引擎需要考虑以下几个问题:

  • 碰到异步事件怎么做?
  • 如何知道异步事件已完成?
  • 如果有多个异步事件,先执行哪个?

 为了解决异步事件的执行顺序问题,JS引擎产生了一个机制,那就是Event Loop(事件循环)。通过这个机制,JS引擎对异步事件进行处理,一次处理一件。

事件循环(Event Loop)


​  首先我们要明确的是在最高层级,同步事件先于异步事件执行。那么如何执行多个异步事件,就需要「事件循环」的机制来处理多个异步事件的先后顺序。

事件循环1

解读:

  • 同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入Event Table并注册函数
  • 当指定的事情完成时,Event Table会将这个函数移入Event Queue
  • 主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行
  • 上述过程会不断重复,也就是常说的Event Loop(事件循环)

 我们不禁要问了,那怎么知道主线程执行栈为空呢?js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数。

宏任务和微任务


​  如上面的流程图所示,异步任务会首先进入Event Table并注册了回调函数,当指定的事情完成时,Event Table会将这个函数移入Event Queue。问题来了,不同的异步事件,会进入不同的Event Queue。这里就进一步把异步事件划分为宏任务和微任务。

事件循环2

 举个例子,去银行办理业务,首先需要取号等到叫到自己。一般上边都会印着类似:“您的号码为XX,前边还有XX人。”之类的字样。因为一个窗口的柜员同时只能处理一个来办理业务的客户,对于柜员来说,每一个来办理业务的客户都是一个宏任务。当柜员处理完当前客户的问题以后,选择接待下一位,广播叫号,也就是下一个宏任务的开始。类比Excel中的「宏」的概念,这里的「宏」有着整体的概念。

​  所以多个宏任务合在一起就可以说一个任务队列在这,队列里是当前银行中所有排号的客户。任务队列中的都是已经完成的异步操作,而不是说注册一个异步任务就会被放在这个任务队列中,就像在银行中排号,如果叫到你的时候你不在,那么你当前的号牌就作废了,柜员会选择直接跳过进行下一个客户的业务处理,等你回来以后还需要重新取号。  而且一个宏任务在执行的过程中,是可以添加一些微任务的,就像在柜台办理业务,你前面的一位老大爷可能在存款,在存款这个业务办理完之后,柜员询问大爷还有没有其他要办的业务。大爷说像办理理财,那么柜员会继续为大爷办理理财业务,而不会直接办理下一位客户的业务。所以本来快轮到你来办理业务,会因为老大爷临时添加的“理财业务”而往后推。也许老大爷在办完理财以后还想 再办一个信用卡?或者 再买点儿纪念币?无论是什么需求,只要是柜员能够帮她办理的,都会在处理你的业务之前来做这些事情,这些都可以认为是微任务。

​  这里就说明了同一批次的微任务上下文是相同的,依赖于当前的宏任务。 所以当一个宏任务完成后,主线程会询问有没有微任务需要处理,只能处理完了当前所有的微任务,才会开始下一个宏任务

​  下面看一下我之前弄错了的题目:

console.log(1)
setTimeout(()=>{console.log(2)},1000)
async function fn(){
    console.log(3)
    setTimeout(()=>{console.log(4)},20)
    return Promise.reject()
}
async function run(){
    console.log(5)
    await fn()
    console.log(6)
}
run()
//需要执行150ms左右
for(let i=0;i<90000000;i++){}
setTimeout(()=>{
    console.log(7)
    new Promise(resolve=>{
        console.log(8)
        resolve()
    }).then(()=>{console.log(9)})
},0)
console.log(10)
// 1 5 3 10 4 7 8 9 2

在做这道题之前需要明确几个概念:

  • 基于微任务的技术有 MutationObserver、Promise 以及以 Promise 为基础开发出来的很多其他的技术,本题中resolve()、await fn()都是微任务。
  • Promise中then里面注册的回调是异步的,但是Promise本身是同步的。也就是说new Promise在实例化的过程中所执行的代码都是同步进行的,而then中注册的回调才是异步执行的。
  • 不管宏任务是否到达时间,以及放置的先后顺序,每次主线程执行栈为空的时候,引擎会优先处理微任务队列,处理完微任务队列里的所有任务,再去处理宏任务。
  • 所有会进入的异步都是指的事件回调中的那部分代码

接下来我们一步一步分析:

  • 第一行代码console.log(1)是同步任务,直接打印出1

  • 第二行代码碰到了setTimeout,异步任务并且属于宏任务。首先将setTimeout放进Event Table,并注册回调函数,在1000ms之后,再将回调函数放入宏任务队列。所以此时宏任务队列暂时为空

    Event Table宏任务队列微任务队列
    setTimeout1(1000ms)
  • 接下来是执行run(),虽然函数run,前使用了async关键词,表示内部有异步事件。但是不影响函数其他同步代码执行。代码console.log(5)执行,打印5。然后碰到了关键词await,继续执行函数fn,代码console.log('3')执行,打印3。这时又碰到了setTiemout,将其放入Event Table中。再看,setTimeout后面的Promise.reject属于微任务,将其放入微任务队列。

    Event Table宏任务队列微任务队列
    setTimeout1(1000ms)Promise.reject()
    setTimeout2(20ms)

 这里需要注意的是:由于函数fn没有执行完成,awit fn()后面的代码是不会执行的,浏览器会继续执行后面的for循环。

  • 然后执行for循环,由于for循环属于同步任务,这个时候主线程的工作就是执行for循环逻辑,即使循环里面为空,也需要花费150ms。当150ms之后,for循环执行完成,此时Event Table里面的20ms的setTimeout也已经到了时间,回调函数开始被放入宏任务队列了。

    Event Table宏任务对列微任务队列
    setTimeout1(1000ms)setTimeout2(20ms)Promise.reject()
  • 主线程继续执行代码,又又又碰到了setTimeout,这个时候依旧放入Event Table中。

  • 接着执行代码console.log(10),打印10。本次宏任务结束,本次大的脚本视为一次宏任务。

  • 宏任务结束后,主线程询问是否有微任务需要执行,此时微任务中存在Promise.reject(),执行这个任务。然后函数run中的await fn()完成,注意因为await右边的Promise返回的是reject,所以后面的代码都不会执行。微任务执行完成。

    Event Table宏任务队列微任务队列
    setTimeout1(1000ms)setTimeout2(20ms)
    setTimeout3(0ms)
  • 下面开始下一次的宏任务,执行setTimeout2的回调函数,请注意进入任务队列的都是已经完成的异步事件的回调函数。setTimeout2的回调函数开始执行,console.log(4)打印出4

    Event Table宏任务队列微任务队列
    setTimeout1(1000ms)setTimeout3(0ms)
  • 下面继续执行下一个宏任务setTimeout3的回调函数。console.log(7)打印7。然后碰到new Promise,执行同步代码console.log(8)打印8。将then里注册的回调函数放入微任务队列。

    Event Table宏任务队列微任务队列
    setTimeout1(1000ms)then
  • 本次宏任务执行结束,开始执行微任务,console.log(9)执行,打印9

  • 此时如果时间还没有到1000ms,那么需要等待时间完成后,将setTimeout1放入宏任务队列中。

    Event Table宏任务队列微任务队列
    setTimeout1(1000ms)
  • 最后执行宏任务setTimeout1,console.log(2)执行,打印2

    到此,代码全部执行完毕。

实际例题

  1. 题目一
const myPromise = () => Promise.resolve('1')
const myPromise2 = () => Promise.resolve('2')

function firstFunction() {
  myPromise().then(res => console.log(res))
  console.log('3')
}

async function secondFunction() {
  console.log(await myPromise2())
  console.log('4')
}

firstFunction()
secondFunction()
  1. 题目二
function test() {
  setTimeout(() => {
    Promise.resolve(4).then(res => { console.log(res); })
    Promise.resolve(7).then(res => { console.log(res); })
    setTimeout(() => {
      console.log(3);
    })
  })
  setTimeout(() => {
    Promise.resolve(9).then(res => { console.log(res); })
  })
  Promise.resolve(1).then(res => { console.log(res); })
  Promise.resolve(2).then(res => { console.log(res); })
}
test()
  1. 题目三
function test() {
  setTimeout(() => {
    Promise.resolve(4).then(res => { console.log(res); })
    Promise.resolve(7).then(res => { console.log(res); })
    setTimeout(() => {
      console.log(3);
    }, 400)
  }, 400)
  setTimeout(() => {
    Promise.resolve(9).then(res => { console.log(res); })
  })
  Promise.resolve(1).then(res => { console.log(res); })
  Promise.resolve(2).then(res => { console.log(res); })
}
test()

参考文章

  1. 你真的懂Promise吗?
  2. JS事件循环机制(event loop)之宏任务/微任务
  3. 微任务、宏任务与Event-Loop