⭐⭐从一道经典题来弄懂Eventloop(通俗易懂)


theme: cyanosis

前言

时间不知不觉来到了11月底,马上也要准备一下寒假的实习了。
最近打算把面试中的一些拦路虎给解决掉!!

先拿臭名昭著的Eventloop开刀~

经典题


async function foo() {
    console.log('foo')
}
async function bar() {
    console.log('bar start')
    await foo()
    console.log('bar end')
}
console.log('script start')
setTimeout(function () {
    console.log('setTimeout')
}, 0)
bar();
new Promise(function (resolve) {
    console.log('promise executor')
    resolve();
}).then(function () {
    console.log('promise then')
})
console.log('script end')

今天刚准备手刀Eventloop的时候,差点就被这个题劝退了😭😭

但现在我终于能拿捏它了~😁😁

Eventloop(事件循环):是怎么让页面活起来的

我们都知道主线程是非常繁忙的,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务。

这个统筹调度系统就是消息队列事件循环系统

使用单线程处理安排好的任务

我们先从最简单的场景讲起,比如有如下一系列的任务:

任务 1:1+2

任务 2:20/5

任务 3:7*8

任务 4:打印出任务 1、任务 2、任务 3 的运算结果

在上面的执行代码中,我们把所有任务代码按照顺序写进主线程里,等线程执行时,这些任务会按照顺序在线程中依次被执行;等所有任务执行完成之后,线程会自动退出。可以参考下图来直观地理解下其执行过程:

image.png

在线程运行过程中处理新任务

但并不是所有的任务都是在执行之前统一安排好的,大部分情况下,新的任务是在线程运行过程中产生的。比如在线程执行过程中,又接收到了一个新的任务要求计算“10+2”,那上面那种方式就无法处理这种情况了。

要想在线程运行过程中,能接收并执行新的任务,就需要采用事件循环机制

image.png

处理其他线程发送过来的任务

面我们改进了线程的执行方式,引入了事件循环机制,可以让其在执行过程中接受新的任务。不过在第二版的线程模型中,所有的任务都是来自于线程内部的,如果另外一个线程想让主线程执行一个任务,利用第二版的线程模型是无法做到的。

那么如何设计好一个线程模型,能让其能够接收其他线程发送的消息呢?

一个通用模式是使用消息队列。

image.png

有了队列之后,我们就可以继续改造线程模型了,改造方案如下图所示:

image.png

从上图可以看出,我们的改造可以分为下面三个步骤:

  1. 添加一个消息队列;

  2. IO 线程中产生的新任务添加进消息队列尾部;

  3. 渲染主线程会循环地从消息队列头部中读取任务,执行任务。

处理其他进程发送过来的任务

通过使用消息队列,我们实现了线程之间的消息通信。在 Chrome 中,跨进程之间的任务也是频繁发生的,那么如何处理其他进程发送过来的任务?你可以参考下图:

image.png

从图中可以看出,渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程,后续的步骤就和前面讲解的“处理其他线程发送的任务”一样了,这里就不再重复了。

所以事件循环系统大致是被这样搭建起来的。

更详细的内容可以参考李兵老师的课程👉15 | 消息队列和事件循环:页面是怎么“活”起来的? (geekbang.org)

什么是Eventloop?

回到定义,那么什么是Eventloop,它的机制是什么样的呢?

同步任务直接执行并且在执行完后出栈,继而执行异步任务,而异步任务又有微任务宏任务的划分,分别被推入进入微任务队列和宏任务队列

  • 宏任务队列中,按照入队顺序,找到第一个执行的宏任务,放入调用栈,开始执行
  • 执行完该宏任务下所有同步任务后,即调用栈清空后,该宏任务被推出宏任务队列,然后微任务队列开始按照入队顺序,依次执行其中的微任务,直至微任务队列清空为止;当微任务队列清空后,一个事件循环结束;
  • 接着从宏任务队列中,找到下一个执行的宏任务,开始第二个事件循环,直至宏任务队列清空为止。

宏任务与微任务

在这里想细说一下宏任务与微任务

宏任务

前面我们已经介绍过了,页面中的大部分任务都是在主线程上执行的,这些任务包括了:

  1. 渲染事件(如解析 DOM、计算布局、绘制);

  2. 用户交互事件(如鼠标点击、滚动页面、放大缩小等);

  3. JavaScript 脚本执行事件;网络请求完成、文件读写完成事件。

为了协调这些任务有条不紊地在主线程上执行,页面进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。我们把这些消息队列中的任务称为宏任务。

简单来说宏任务就是:

setTimeout、setInterval、 setImmediate、script(整体代码)、I/O(输入输出) 操作等

微任务

微任务是怎么产生的?

一个典型的场景是监控 DOM 节点的变化情况(节点的插入、修改、删除等动态变化),然后根据这些变化来处理相应的业务逻辑。一个通用的设计的是,利用 JavaScript 设计一套监听接口,当变化发生时,渲染引擎同步调用这些接口。

不过这有个问题,因为 DOM 变化非常频繁,如果每次发生变化的时候,都直接调用相应的 JavaScript 接口,那么这个当前的任务执行时间会被拉长,从而导致执行效率的下降。

如果将这些 DOM 变化做成异步的消息事件,添加到消息队列的尾部,那么又会影响到监控的实时性,因为在添加到消息队列的过程中,可能前面就有很多任务在排队了。

这也就是说,如果 DOM 发生变化,采用同步通知的方式,会影响当前任务的执行效率;如果采用异步方式,又会影响到监控的实时性。

那该如何权衡效率和实时性呢?

针对这种情况,微任务就应用而生了

下面我们来看看微任务是如何权衡效率和实时性的。通常我们把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,因此也就解决了执行效率的问题。等宏任务中的主要功能都直接完成之后,这时候,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务,因为 DOM 变化的事件都保存在这些微任务队列中,这样也就解决了实时性问题。

而简单来说微任务就是:
process.nextTickPromise.then()、MutationObserver、Async/Await 等

是先宏再微还是先微再宏

因为我发现很多小伙伴在准备这一块的内容时,看了一下文章视频,可能就只死记硬背:先微任务再宏任务

当然我相信你这样记,肯定对你解题没错的。
但是上面我们站在微任务是如何产生时说了,原理上是先执行宏任务再进行微任务的,而且结果都没有错,这是为什么?

这是因为认为先微任务再宏任务的同学已经把script(整体代码)作为一个宏任务先判断掉了,所以后面直接找微任务就行了。

但个人觉得最好还是从原理出发进行理解先执行宏任务再进行微任务比较好,而且也就多了判断script(整体代码)这一点而已了~

async/await

要想把那道题磕出来,还得掌握async/await。

async

我们先来看看 async 到底是什么?

根据 MDN 定义,async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。对 async 函数的理解,这里需要重点关注两个词:异步执行隐式返回 Promise

关于异步执行的原因,我们一会儿再分析。这里我们先来看看是如何隐式返回 Promise 的,你可以参考下面的代码:


async function foo() {
    return 2
}
console.log(foo())  // Promise {<resolved>: 2}

执行这段代码,我们可以看到调用 async 声明的 foo 函数返回了一个 Promise 对象,状态是 resolved,返回结果如下所示:


Promise {<resolved>: 2}

await

我们知道了 async 函数返回的是一个 Promise 对象,那下面我们再结合文中这段代码来看看 await 到底是什么。


async function foo() {
    console.log(1)
    let a = await 100
    console.log(a)
    console.log(2)
}
console.log(0)
foo()
console.log(3)

我们先站在协程的视角来看看这段代码的整体执行流程图:

如果不太理解协程是什么的话,可以自己查找资料或者参考李兵老师的课程👉👉20 | async/await:使用同步的方式去写异步代码 (geekbang.org)

image.png

首先,执行console.log(0)这个语句,打印出来 0。

紧接着就是执行 foo 函数,由于 foo 函数是被 async 标记过的,所以当进入该函数的时候,执行 foo 函数中的console.log(1)语句,并打印出 1。

接下来就执行到 foo 函数中的await 100这个语句了,这里是我们分析的重点,因为在执行await 100这个语句时,JavaScript 引擎在背后为我们默默做了太多的事情,那么下面我们就把这个语句拆开,来看看 JavaScript 到底都做了哪些事情。

当执行到await 100时,会默认创建一个 Promise 对象,代码如下所示


let promise_ = new Promise((resolve,reject){
  resolve(100)
})

然后 JavaScript 引擎会暂停当前协程的执行,将主线程的控制权转交给父协程执行,同时会将 promise_ 对象返回给父协程。

主线程的控制权已经交给父协程了,这时候父协程要做的一件事是调用 promise_.then 来监控 promise 状态的改变。

接下来继续执行父协程的流程,这里我们执行console.log(3),并打印出来 3。随后父协程将执行结束,在结束之前,会进入微任务的检查点,然后执行微任务队列,微任务队列中有resolve(100)的任务等待执行,执行到这里的时候,会触发 promise_.then 中的回调函数,如下所示:


promise_.then((value)=>{
   //回调函数被激活后
  //将主线程控制权交给foo协程,并将vaule值传给协程
})

该回调函数被激活以后,会将主线程的控制权交给 foo 函数的协程,并同时将 value 值传给该协程。

foo 协程激活之后,会把刚才的 value 值赋给了变量 a,然后 foo 协程继续执行后续语句,执行完成之后,将控制权归还给父协程。

所以处理async和await的时候只需要记到:
其中 await 前面的代码 是同步的,调用此函数时会直接执行;而 await foo(); 这句可以被转换成 Promise.resolve(foo())await 后面的代码 则会被放到 Promise 的 then() 方法里。

当然因为这里分析的比较详细,简单一点分析可以参考下面:

  1. console.log(0)
  2. 执行foo()函数,打印 console.log(1)
  3. await后面的语句添加到微任务中[ console.log(a)
    console.log(2)]
  4. 打印console.log(3)
  5. 当前宏任务执行完毕,执行微任务 console.log(a)
    console.log(2)

秒杀经典题

现在我们再来梳理一下现在解题的方法

Eventloop处理流程

  • 宏任务队列中,按照入队顺序,找到第一个执行的宏任务,放入调用栈,开始执行
  • 执行完该宏任务下所有同步任务后,即调用栈清空后,该宏任务被推出宏任务队列,然后微任务队列开始按照入队顺序,依次执行其中的微任务,直至微任务队列清空为止;当微任务队列清空后,一个事件循环结束;
  • 接着从宏任务队列中,找到下一个执行的宏任务,开始第二个事件循环,直至宏任务队列清空为止。

宏任务

setTimeout、setInterval、 setImmediate、script(整体代码)、I/O(输入输出) 操作等

微任务
process.nextTickPromise.then()、MutationObserver、Async/Await 等

async/await

  • await 前面的代码 是同步的,调用此函数时会直接执行;
  • await foo() 这句可以被转换成 Promise.resolve(foo())(同步);
  • await 后面的代码 则会被放到 Promise 的 then() 方法里,加入微任务队列
    上题!!
async function foo() {
    console.log('foo')  // 3
}
async function bar() {
    console.log('bar start') // 2
    await foo()
    console.log('bar end')  // 6
}
console.log('script start')  // 1
setTimeout(function () {
    console.log('setTimeout')  // 8
}, 0)
bar();
new Promise(function (resolve) {
    console.log('promise executor') // 4
    resolve();
}).then(function () {
    console.log('promise then')  // 7 
})
console.log('script end') // 5 

分析流程:

  1. foo函数和bar函数都是对函数的定义,并不会执行(过)
  2. console.log(‘script start‘) 直接打印
  3. 接着将 settimeout 添加到宏任务队列,此时宏任务队列为 ['settimeout']
  4. 执行bar函数
  5. console.log(‘bar start‘) 直接打印
  6. 执行foo函数
  7. console.log(‘foo‘) 直接打印
  8. console.log(‘bar end‘) 加入微任务队列['bar end']
  9. new Promise内部是同步代码,所以直接打印 console.log(‘promise executor‘)
  10. new Promise().then()是微任务,所以再将 console.log(‘promise then’) 加入微任务队列['bar end','promise then']
  11. 直接打印console.log(‘script end‘)
  12. 当前宏任务中执行完了,就去找微任务['bar end','promise then'],依次输出
  13. 这一次的事件循环结束,开始执行下一轮的宏任务['settimeout']

是不是发现这种题真的很简单,套路都是套路。

😋😋😋

小结

最好后面再去找几道题练一练,自我感觉这一块远远没有之前那么难了。
不枉费我一个下午的时间。希望能帮助到屏幕前的你了~🚀🚀🚀

本文正在参加「金石计划 . 瓜分6万现金大奖」

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容