少年,且听我细说 EventLoop/宏任务/微任务是咋玩的?

系列文章:

  1. 先撸清楚:并发/并行、单线程/多线程、同步/异步
  2. 论Promise在前端江湖的地位及作用
  3. 少年,且听我细说 EventLoop/宏任务/微任务是咋玩的?

前言

上篇文章分析了Promise的重要性以及使用上容易犯错的点,本篇将重点分析EventLoop/宏任务/微任务在浏览器和Node.js里的表现。
通过本篇文章,你将了解到:

  1. JS引擎与运行时
  2. 浏览器里的进程和线程
  3. JS单线程如何运作
  4. 宏任务/微任务–相得益彰
  5. 宏任务/微任务易混点实战
  6. 一些未解的讨论

1. JS引擎与运行时

1.1 JS 引擎

1.1.1 简单概念

JavaScript 是门语言,使用JS编写的源代码是程序员可以看懂的,而机器(CPU)最终识别的是机器码,因此需要一个工具将源代码转为机器码,这个工具称为JS引擎。
image.png

1.1.2 常见的JS引擎

Chrome V8:
Google 使用C++开发的高效、开源的JS引擎,目前主要用在Node.js和Chrominum(Chrome开源版本)系列的浏览器上。

Chakra:
微软开发开源的JS引擎,目前主要用在Internet Explorer和Microsoft Edge浏览器上。

JavaScriptCore:
苹果开发,主要用在Safari浏览器上。

SpiderMonkey:
以前用在Netscape Navigator上,现在用在Firefox上。

还有一些其他的JS引擎,如:Rhino、KJS、Nashorn等。

1.2 JS运行时

有了JS引擎就可以执行JS代码了,但这还不够,单纯的JS代码并不能满足我们的业务需求,比如网络请求,I/O操作等,于是需要一套环境将JS引擎和相关的API整合起来。
image.png

我们知道JS是单线程语言,而网络请求是耗时的任务,JS是怎么处理这些耗时任务的呢?不可能干等着每个耗时任务的完成吧?怎么做呢?
答案是异步任务,当异步任务处理完成后,JS引擎线程再执行异步任务的回调函数,因此这套环境还需要一个机制来处理不同的异步任务的回调,异步任务的回调将会被放入回调队列里等待合适的时机执行,如下图:
image.png

通过EventLoop(事件循环)处理回调队列里的函数,从而使得JS能够操作异步任务。
JS运行时也称JS环境、JS宿主(中文资料通常称它)。

常见的JS宿主有哪些呢:

  1. 各类的浏览器(如Chrome、Safari、Edge)
  2. 用于服务端的Node.js

1.3 JS/H5 规范

ECMAScript是JavaScript的标准,比如规定了变量、函数等的写法以及一些语言上的特性。通常说的某某JS引擎支持ECMAScript5.1、ES6、ES7等,指的是该引擎实现了ECMAScript标准哪个版本的规范。
ECMAScript

HTML5的标准是由WHTWG和W3C共同制定(HTML5)

2. 浏览器里的进程和线程

2.1 单进程时代

上面只是宏观说明了宿主实现的一些功能,本节从微观的角度(进程/线程)说明这些功能是怎么分布的。
早期的浏览器是单进程实现的,该进程里包含了一些线程。
image.png
单进程优势:
没有进程间通信,比较简单。

劣势:

  1. 不稳定,比如插件崩了就会影响浏览器的其它功能
  2. 不流畅,单个页面线程里承载了太多功能,影响渲染效率

2.2 多进程时代

后面浏览器进化到了多进程时代:
image.png
多进程的劣势:
占用更多的系统资源(内存、CPU等)

多进程的优势:

  1. 稳定,如插件在单独的进程,因为进程之间是隔离的,即使插件进程有问题也不会影响浏览器的核心功能
  2. 每个Tab新开进程单独处理,充分利用多核CPU并行能力

不是每个应用场景下都严格按照多进程来实现,多进程是可以配置的,比如在嵌入式设备上的浏览器就不会有那么多的进程,毕竟资源有限,可以配置为共享进程。

3. JS单线程如何运作

3.1 看病的Demo

3.1.1 同步任务

上面说的点貌似和题目不符呢?其实以上内容仅仅只是打下基础,我们需要有个印象哪些功能是哪个模块实现的,是同一线程还是不同的线程运行的。
在讲EventLoop之前先看个小Demo:
image.png

我们将门诊医生比作JS引擎线程,单个医生同一时间只能为一个病患看病,没轮到的病患需要排队。
正常的就诊过程是这样的流程:

  1. 病患自诉病情,医生边问边记录
  2. 医生最终给出结论,开药或做其它项检测

3.1.2 异步任务

若每位病患都是:"陈述病情–>开药"流程,就诊的效率就会很高(JS主线程执行同步任务),然而实际场景大都是"陈述病情–>设备检测–>分析报告–>开药"流程。
以病患1为例说明整个流程:

  1. 病患1:医生我最近经常感觉到头痛
  2. 医生:我给你开个单,你去拍个CT看看,叫号:下一位进来
  3. 病患2:医生我拉肚子了,喷射了好几天都不停歇…
  4. 医生:有可能是肠胃感染了,我给你开个单,你先去抽个血,看看是不是细菌感染还是病毒感染,叫号:下一位进来
  5. 医生:继续处理后续的病患,叫号:下一位…下一位…
  6. 病患1:医生CT报告出来了,帮忙看看呢
  7. 医生:片子没看出问题,我给你开几副安神补脑的药,回去多休息,如果还有不适再来就诊,叫号:下一位
  8. 病患2:医生我化验单出来了,帮我看看呢
  9. 医生:你这是细菌感染,我先给你开止泻的药,平时多注意个人卫生,饭前便后要洗手…

由上片段可以看出当前的就诊医生并没有直接给病患进行CT或抽血,他如果做了,那么后续等待的病患就不能及时得到就诊,影响了整个就诊的效率,因此此时选择让另一位医生(另一个线程)进行检测,检测的过程类比JS代码里的异步任务,当检测结果出来后重新找医生看报告(异步任务结果回调)。
image.png

通过与其他同事(线程)配合,就诊医生能够高效处理病患的需求。
当然啦,如果我们将医生比作线程,医院比作进程,那么上述的就诊过程均发生在同一家医院(同一进程),若是该医院的CT设备不够先进,无法做相应的检查,那么医生可能建议去其它医院(其它进程)做,拿到检验结果后回来再找他看(不同医院传输报告,类似于不同进程间的通信-IPC)。

  1. 因此,JS里的异步任务有可能是不同线程处理,也有可能是不同的进程处理,不过最终的处理结果都在JS引擎线程处理
  2. 异步任务的设计使得单线程能够处理更多的事情

3.2 EventLoop–动静结合

3.2.1 怎么动

有了异步任务,还需要解决哪个任务先执行、它什么时候执行,这些策略都是由EventLoop统筹管理。
EventLoop顾名思义:事件循环,事件指的是任务,任务从队列里来(这里队列不一定是Queue,有可能是其它如Set等),循序指的是不断地从队列里取出任务进行执行。
image.png

你可能看到关于EventLoop的其它论述:

EventLoop检测Call Stack(JS Engine里) 是否为空,若为空则从Callback Queue里取出任务塞到Call Stack里,若不为空则等待Callback为空

如果觉得以上论述不好理解,可以这么理解:

EventLoop是单线程运行,该线程循环不断地处理队列里的任务。

3.2.2 怎么静

如果队列里没有了任务怎么办?当前线程会挂起(不占用CPU),当有新的任务到来时将会唤醒当前挂起的线程,线程被唤醒之后就继续干活(取任务执行)。

3.2.3 其它的EventLoop

不仅JS里有事件循环,其它语言里也有,比如Android里的Looper,再比如iOS里的RunLoop,都是单线程(UI线程/主线程),实现的思路都是类似的。

4. 宏任务/微任务-相得益彰

4.1 宏任务

4.1.1 setTimeout 为例

EventLoop 不同的宿主有不同的实现,以浏览器里的EventLoop为例,EventLoop里的任务主要分为宏(MacroTask)任务和微(MicroTask)任务。
先看代码:

function sayHello() {
    console.log('hello world')
}
sayHello()

这里并没有异步任务,整个同步的代码被当做一个宏任务。
加个异步任务:

function sayHello() {
    console.log('hello world')
    setTimeout(()=>{
        console.log('hello world2')
    }, 2000)
}
sayHello()

总共涉及到了两个宏任务:
第一个是同步的宏任务,它里面产生了一个异步的宏任务,当然在这个宏任务里再开启另一个宏任务:

function sayHello() {
    console.log('hello world')
    setTimeout(()=>{
        console.log('hello world2')
        setTimeout(()=>{
            console.log('hello world3')
        })
    }, 2000)
}
sayHello()

最终按顺序输出:hello world–>hello world2–>hello world3
setTimeout并不是JS Engine实现的,而是由宿主实现的,它可能的实现方式:

  1. setTimeout调用后将回调加入到延迟队列,JS引擎线程不断地检测延迟队列里的任务是否到了可执行时间,若是则执行该任务(setTimeout的回调函数)
  2. setTimeout调用后由定时器线程负责计时,当时间到达后定时器线程往EventLoop队列里加入任务(setTimeout回调函数),等待JS引擎线程执行

可以看出,不论哪种实现方式,setTimeout回调函数执行时间均受JS引擎线程执行时长的影响,如:

function sayHello() {
    let starTime = Date.now()
    console.log('hello world')
    setTimeout(()=>{
        console.log('hello world2 interval:', Date.now() - starTime)
    }, 2000)
}
sayHello()

延时2s打印,此时打印出来的interval刚好就是2s(误差在毫秒级),说明setTimeout还是比较符合预期。
若此时在setTimeout之后做一些耗时的动作:

function sayHello() {
    let starTime = Date.now()
    console.log('hello world')
    setTimeout(()=>{
        console.log('hello world2 interval:', Date.now() - starTime)
    }, 2000)
    for(let i = 0; i < 1e10; i++){}
}
sayHello()

添加了个空循环,最终打印出来的interval是11s。
由此说明了两个问题:

  1. JS代码执行是单线程(JS引擎线程)
  2. 异步任务的回调在JS引擎线程里执行

因此为了让JS引擎线程在单位时间里能够处理更多的宏任务,需要降低每个宏任务的耗时。

4.1.2 其它的宏任务

除了setTimeout,还有其它的宏任务:

  1. setInterval、setImmediate(Node.js)事件
  2. 用户交互(鼠标悬停、点击等事件)
  3. I/O(网络和文件事件)
  4. 页面渲染相关事件

4.2 微任务

4.2.1 Promise为例

还是上面医生的例子,每位病患当做宏任务,当病患1的CT结果出来后交给医生查看,病患1的CT片子好几个,医生要多花时间分析。
当医生正在查看病患1的片子时,其他病患说你们怎么看了那么久?剩下的片子能不能待会再看呢,先给我看看吧?
此种场景下医生当然不能答应了,毕竟他要一起看完了所有片子才能下结论,不能半途而废,不然又要重新来看,于是医院就规定:

其他病患需要等待医生看完当前病患的所有片子

我们把病患当做宏任务,那么把医生看病患的片子当做微任务,在微任务未执行完成之前,其它宏任务是不能执行的。

EventLoop是宿主实现的,微任务则是JS Engine实现的,微任务典型的代表:Promise。
先看Demo:

function sayHello() {
    new Promise((resolve, reject) => {
        console.log('before resolve')//同步执行
        resolve('hello world')
    }).then(value => {
        console.log(value)//异步执行
    })
    console.log('sayHello end')//同步执行
}

sayHello()

当第一个宏任务执行结束(sayHello end)后,Promise.then回调执行。
需要注意的是:此时的’before resolve’是同步执行的。

JS Engine维护了微任务队列,每当一个宏任务执行完毕后就会检查微任务队列是否有任务,如有依次拿出来执行,等到微任务队列清空后再执行下一个宏任务。

那Promise什么时候加入到微任务队列呢?

Promise.then()/Promise.catch()/await Promise执行的时候并没有把回调加入到微任务队列,而是当Promise状态由pending变为fullfilled或rejected时才加入的微任务队列

function sayHello() {
    new Promise((resolve, reject) => {
        console.log('before resolve')//同步执行
        setTimeout(()=>{
            resolve('hello world')
        })
    }).then((value)=>{
        console.log(value)
    })

    new Promise((resolve, reject)=>{
        resolve('hello world2')
    }).then(value => {
        console.log(value)
    })
    console.log('sayHello end')//同步执行
}

sayHello()

如上,尽管第一个Promise的then()先执行,但是却是第二个Promise的then()回调先执行。

4.2.2 其它的微任务

除了Promise.then Promise.catch Promise.finally await,还有其它微任务,如:
MutationObserver、process.nextTick(Node.js)、queueMicrotask

4.3 微任务/宏任务结合

如果存在第二个宏任务,看看和微任务执行时序:

function sayHello() {
    //添加一个宏任务
    setTimeout(()=>{
        console.log('timeout')
    })
    new Promise((resolve, reject) => {
        console.log('before resolve')//同步执行
        resolve('hello world')
    }).then(value => {
        console.log(value)//异步执行
    })
    console.log('sayHello end')//同步执行
}
sayHello()

可以看出,'hello world’先于’setTimeout’打印,虽然书写顺序上setTimeout在前,Promise在后,但是第一个宏任务结束后会先去寻找微任务执行,执行完所有微任务后再执行下个宏任务。

再来看多个宏任务+多个微任务的场景:

function sayHello() {
    //添加一个宏任务
    setTimeout(()=>{
        console.log('timeout')
        new Promise((resolve, reject) => {
            resolve('hello world3')
        }).then(value => {
            console.log('第三个微任务回调', value)//异步执行
        })
    })
    new Promise((resolve, reject) => {
        console.log('before resolve')//同步执行
        resolve('hello world')
    }).then(value => {
        console.log('第一个微任务回调', value)//异步执行
        return 'hello world2'
    }).then(value => {
        console.log('第二个微任务回调', value)//异步执行
    })
    console.log('sayHello end')//同步执行
}

sayHello()

第一个宏任务关联了两个微任务(第一个微任务和第二个微任务),第二个宏任务关联了一个微任务(第三个微任务),最终打印结果:

before resolve
sayHello end
第一个微任务回调 hello world
第二个微任务回调 hello world2
timeout
第三个微任务回调 hello world3

最后看看浏览器里的EventLoop+宏任务+微任务+渲染的流程。
image.png

  1. 先执行宏任务
  2. 宏任务执行完后查看微任务队列,若有微任务则执行
  3. 微任务执行完后查看是否需要渲染,若需要则进行渲染
  4. 从宏任务队列里找出宏任务,重复1~3

注:以上步骤均在JS引擎线程执行。
宏任务里可以发起微任务,也可以发起宏任务,微任务里可以发起微任务,也可以发起宏任务。

5. 宏任务/微任务易混点实战

5.1 Promise里executor同步执行

new Promise((resolve, reject)=>{
    console.log(1)
    resolve(3)
}).then(value => {
    console.log(value)
})
console.log(2)

打印结果:1 2 3
console.log(1)是executor里的代码,它是同步执行的

5.2 async 函数是同步执行

function buildPromise() {
    return Promise.resolve('1')
}

async function test() {
    console.log(2)
    let value = await buildPromise()
    console.log(value)
    console.log(3)
}

console.log(4)
test()
console.log(5)

打印结果:4 2 5 1 3
test函数里在没有遇到await 前是同步执行的,因此2在5之前打印

5.3 await 函数是异步执行的

使用5.2的例子,1、3在5之后打印,await 等待Promise完成返回

5.4 宏/微互相嵌套

先执行完微任务再执行宏任务。

new Promise((resolve, reject)=>{
    console.log(1)
    setTimeout(()=>{
        Promise.resolve(2).then(value => {
            console.log(value)
        })
    })
    new Promise((resolve, reject)=>{
        console.log(5)
        setTimeout(()=>{
            resolve(6)
        })
    }).then(value => {
        console.log(value)
    })
})
setTimeout(()=>{
    Promise.resolve(3).then(value => {
        console.log(value)
    })
    setTimeout(()=>{
        console.log(7)
    })
})
console.log(4)

打印结果:1 5 4 2 6 3 7

6. 一些未解的讨论

6.1 执行时序不一样

有可能你执行上述的Demo结果和文中描述的不一致,或者在实际使用过程中发现和网上说的不一致,不要担心。
EventLoop是宿主实现的,不同的宿主,比如Chrome和Node.js实现方式会有差异(现在两者在SetTimeout和Promise处理时序都趋同了),同一宿主的不同版本都可能有差异,当遇到差异时对比不同的实现方式,找到差异原因进行控制处理。

6.2 只有一个宏任务队列吗?

有些说宏任务队列有多个,不同的宏任务队列的优先级不一样,比如在浏览器里同样是宏任务,渲染任务的优先级高于定时器任务。
你觉得呢?

本篇简单梳理了一些前端的基础知识,我们下篇来整整装饰器,让你的代码"装起来",敬请关注~

  • 11
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值