透析js事件循环机制event-loop

1,170 阅读12分钟

js事件循环机制基本上面试都会问的,如果你了解还不是很透彻,不妨现在耐下心来看下去。先给你一道面试题,答案放到文章最后

面试官:请问下面的代码输出顺序是什么

console.log('script start')  
async function async1() {
    await async2() 
    console.log('async1 end') 
}
async function async2() { 
    console.log('async2 end')
}
async1()
setTimeout(function () { 
    console.log('setTimeout')  
}, 0)
new Promise(resolve => {
    console.log('Promise') 
    resolve()
})
    .then(function () {
        console.log('promise1')
    })
    .then(function () {
        console.log('promise2')  
    })
console.log('script end')

event-loop之前你需要了解的知识

JavaScript为何是一门单线程语言

这个问题面试中也会常问

在回答这个问题之前,我们需要先明白什么是进程,以及什么是线程

进程

进程是CPU运行指令和保存上下文所需要的时间,他是个相对描述

如何理解呢?

进程其实就是咱们电脑任务管理器中你强行结束的东西,对于浏览器来说就是一个tab页面就是一个进程。以前老版本浏览器是单线程的,你如果多开了页面可能直接死机,所有页面都运行不了了。上面的概念是从底层来看的,也就是从CPU的角度,他是个工作时间片,比如你双击运行微信,然后电脑分配一个空间(执行上下文)这一过程的时间就是一个进程

线程

进程中更小的单位,描述的一段指令执行所需的时间

如何理解呢?

我们以浏览器页面加载为例,当你回车一个url到页面完整展现给你看的时候,这中间其实发生了很多事情。这一过程我们也称之为页面的渲染

整个浏览器的进程主要有:GPU渲染线程、http请求线程、js引擎线程。所以说一个进程由多个线程配合工作,这时就会创建一个执行上下文,也就是执行的内存空间。需要多个线程配合工作才能完成页面的展现。

这里简单介绍下GPU

GPU

GPU是图形处理单元,英文全称:Graphics Processing Unit

浏览器中GPU就是负责绘制功能,找到页面中物理发光点会发什么光,你可以将他理解成一个画笔,GPU渲染页面的过程我们称之为GPU渲染线程

这里还有个经典面试问题:

js执行能否和html渲染同时进行?

这两个线程是不能同时进行的,js执行会阻塞html的渲染,这是因为js能操作dom结构,若能同时进行,就会造成一个不安全的渲染

好了,我们现在来回答一下前面的问题,为何js是一门单线程语言?

答案

这是因为js语言设计之初仅仅就是一个脚本语言,是用来给页面辅助动态交互作用的。多线程就意味着高并发,相同时间内占据的内存会很多,其目的就是为了降低这门语言对设备性能的开销,另外一个优点就是节约上下文切换的时间

如何理解节约上下文切换的时间?

以java这门语言为栗子🌰,java是有锁这个概念的,因为他是多线程,多线程必然会导致一个冲突问题,比如同时对一个变量赋值,因此我们可以人为上锁,让他运行或者不运行,当你遇到锁的时候,执行顺序就会去找那个锁的地方,这一过程就是上下文的切换,并且会耗时

JavaScript异步

JavaScript异步又分为宏任务,微任务

js语言设计之初发现js代码仅仅分为同步,异步是不够的,异步代码分为宏任务和微任务可以让开发者更精细地控制异步代码的执行顺序

宏任务(macro-task)

  • script
  • setTimeout
  • setInterval
  • setImmediate:指定的dom结构加载完毕再执行,一般框架中用的多
  • I/O:输入输出事件,比如用户点击按钮,就是一个输入事件
  • UI-rendering:页面渲染,与v8引擎关系不大

这里面最常见的宏任务就是setTimeout

微任务(micro-task)

  • promise.then:Promise是同步,Promise.then是异步
  • MutationObserver:dom结构发生变化做出反应,这是个高级方法,设计模式用得到
  • Process.nextTick:node中,.argv拿到指令

这里面最常见的微任务就是Promise.then

下面我们来看下事件循环机制event-loop


js事件循环机制(event-loop)

  1. 执行同步代码
  2. 当执行栈(也叫调用栈)为空时,查询是否有异步代码需要执行(有就去下一步)
  3. 执行微任务
  4. 如果有需要,会渲染页面(js和html才需要用这个)
  5. 执行宏任务(也是下一轮的event-loop的开启)

这里需要注意:异步代码耗时,但是耗时的代码不一定就是异步代码

比如下面这个栗子🌰

setTimeout(() => console.log('setTimeout'), 1000)

for(let i = 0; i<10000; i++){
	console.log('hello world')
}

for循环执行这么多次必然是耗时的,但是这个耗时是由电脑的性能(CPU)决定的,而非v8引擎,假设for执行完也需要1s,那么输出setTimeout就是要2s,因为先执行同步代码,后执行异步代码。for就是个同步代码,尽管他是耗时的,不过这个耗时是由cpu决定的

再来个栗子🌰

console.log('start'); //  a
setTimeout(() => {
    console.log('setTimeout') // b
    setTimeout(() => { 
        console.log('inner')  // c
    })
    console.log('end')  // d
}, 1000)
new Promise((resolve, reject) =>{ 
    console.log('Promise') // e
    resolve()
})
.then(() => {
    console.log('then1')  // f
})
.then(() => (console.log('then2')))  // g

面试中要是被问到这种题目,已经算是非常简单的了

做这种题目前一定要牢记event-loop的步骤

为了方便演示,我给每个输出语句都打上了字母。现在开始分析:

从上往下看,a是同步代码,因此输出a,然后碰到个定时器,定时器是异步宏任务,因此先入队列,至于里面的东西我们不管,执行它是第二轮event-loop的事,然后来到Promise,这也是个同步代码,因此输出e,然后后面两个then是异步微任务,接连入微任务队列,目前的 同步已经执行完毕,然后开始执行微任务,then1先执行,then2后执行,输出,宏任务队列如下

// 第一轮event-loop

// 微任务:g f
输出:a e f g
宏任务:setTimeout // 这是最外层的那个setTimeout

队列是先进先出,所以先进的先执行出队

接下来就是执行宏任务了,因此来到第二轮event-loop

对最外层setTimeout里面的代码块进行分析:第一个输出是同步,因此输出b,然后一个定时器,但是人家并不耗时啊!

这里注意,只要是定时器,无论设置时间与否,都是异步代码

好,所以c是个宏任务,入宏队列。然后一个输出是同步,因此输出d,此时输出,宏任务为

// 第二轮event-loop

// 微任务:
输出:b d
宏任务:c

接着执行宏任务c,因此来到了第三轮event-loop,这个setTimeout里面只有一个输出,因此只能执行它,输出c。

// 第三轮event-loop
输出:c

所以最终输出为a、e、f、g、b、d、c。输出结果也确实如此

asyncawait

今天我们只讲事件循环机制,async和await其实考得更多的是手写源码,这个以后我们再聊

asyncawait这两个东西的出现就是因为Promise解决地域回调也不够优雅,Promise这个东西考点这里也不是重点,依旧是手写源码,关于地狱回调,Promise以及源码挖个坑以后再聊

我们看下Promise为何不优雅

举个栗子🌰

function A(){
    return new Promise((resolve, reject) =>{
        setTimeout(() => {
            console.log('异步A完成')
            resolve()
        }, 1000)
    })
}
function B(){
    return new Promise((resolve, reject) =>{
        setTimeout(() => {
            console.log('异步B完成')
            resolve()
        }, 500)
        
    })
}
function C(){
    setTimeout(() => {
        console.log('异步C完成')
    }, 100)
}
A()
.then(() => { 
    return B()
})
.then(() => {
    C()
})
// 输出如下
异步A完成
异步B完成
异步C完成

用promise就解决了ABC函数异步的问题,promise让他们成了同步执行,但是这样写,如果有很多函数需要进行同步,用promise就会有一排then写下去,很不优雅!

因此官方在es7推出asyncawait解决这一问题

function A(){
    return new Promise((resolve, reject) =>{
        setTimeout(() => {
            console.log('异步A完成')
            resolve()
        }, 1000)
    })
}
function B(){
    return new Promise((resolve, reject) =>{
        setTimeout(() => {
            console.log('异步B完成')
            resolve()
        }, 500)
        
    })
}
function C(){
    setTimeout(() => {
        console.log('异步C完成')
    }, 100)
}

async function foo() {
    await A()
    await B()
    await C()
}
foo()
// 输出如下
异步A完成
异步B完成
异步C完成

async函数:声明一个异步函数,该函数返回一个Promise,该函数内部,你可以使用一个await关键字来等待一个Promise解决

这里注意:async可以不写await,但是await写了就必须有async

从这个定义你其实完全可以理解,这个async声明函数就相当于是给函数体内用new Promise包裹了起来,然后里面的awati就相当于.then语法糖,所以await本身就成了异步微任务,这里await还让后面的同步代码变成了异步微任务

语法糖指的是写法更加便利,提供了更加便利的方式来表达一些常见的编程模式

我添加一个同步代码

async function foo() {
    await A()
    console.log('1')
    await B()
    await C()
}
foo()
// 输出如下
异步A完成
1
异步B完成
异步C完成

好了,目前为止,你应该基本上掌握了event-loop,你能否用event-loop来解释下面一个非常常见的代码

function A(){
    setTimeout(() => {
        console.log('异步A完成')
    }, 1000)
}
function B(){
    setTimeout(() => {
        console.log('异步B完成')
    }, 500)
}

A()
B()
// 输出如下
异步B完成
异步A完成

在我学事件循环机制之前,我是可以轻松说出这个输出结果的,但是学完这个事件循环机制之后,你如何解释呢?在第一轮event-loop,AB都是异步宏任务,依次入宏队列:B A。A先入队列,那就是A先出队,那应该是A先执行,也就是到了第二轮event-loop,然后执行B,也就到了第三轮event-loop,既然这样,也应该是先A再B啊?

这是为什么?

其实浏览器针对定时器有针对的消息队列操作他,它的出栈顺序就是根据定时器们的时间决定的,时间短先执行。所以第二轮event-loop就是B,第三轮就是A,这里有个点需要注意,执行完B再执行A这之间的时间是0.5s,定时器是同一时间计时的


答案

先把题目贴这里,然后我再给每个输出语句打上字母标签

console.log('script start') // a
async function async1() {
    await async2() 
    console.log('async1 end') // b
}
async function async2() { 
    console.log('async2 end') // c
}
async1()
setTimeout(function () { 
    console.log('setTimeout')  // d
}, 0)
new Promise(resolve => {
    console.log('Promise') // e
    resolve()
})
    .then(function () {
        console.log('promise1') // f
    })
    .then(function () {
        console.log('promise2')  // g
    })
console.log('script end') // h

总共八个输出,首先,代码从上往下执行,a是同步代码,输出a。然后两个函数体声明我们不管,走到async1()去执行函数,这其实就是Promise函数,因此是个同步代码。因此现在要立即去执行async1这个函数,然后发现里面是个awaitawait上面已经讲过了,就是相当于一个Promise.then异步微任务,因此async2入队列,并且,await让后面的执行语句也变成了微任务,因此log('async1 end')也入微任务队列,此时的微任务队列如下

微任务:log('async1 end')  ->  async2

接着分析,接着遇见了定时器异步代码,这是个异步宏任务,入异步宏任务队列。然后又是个Promise同步函数,输出Promise 。然后是两个.then。也就是异步微任务,第一个.then我就叫.then1了,第二个.then就叫.then2,因此这两个接连入队列,最后输出log('script end')。目前的两个任务队列和输出分别为

宏任务:setTimeout
微任务:then2 -> then1 -> log('async1 end')  ->  async2
输出:a e h 

此时执行完了第一轮的第一步,现在是有异步代码的,于是第三步先去执行微任务队列,接着输出

输出:a e h c b f g 

开启第二轮,执行宏任务队列,最终输出应该为

输出:a e h c b f g d

好,我们现在检查下答案

答案:
script start ---a
async2 end -----c
Promise --------e
script end -----g
async1 end -----b
promise1 -------f
promise2 -------g
setTimeout -----d

纳尼??答案怎么不一致!

好吧,不卖关子了,以前老版本的await确实是我们分析的答案,现在新版本的await提速了,await代码又成同步代码了,不过await依旧还是让后面的代码变成微任务。

既然如此,我们现在再来从上到下分析下:a依旧同步,输出a,async1也是同步代码,进去看看里面的内容,await async2()是同步了,然后执行async2这个函数,也就是输出c,然后b入微任务队列,接下来来到定时器,异步宏任务入队列,然后是Promise同步代码,因此输出e,后面两个then都是微任务,入微任务队列,h同步,输出h。此时的微任务队列和宏任务队列以及输出分别为

微任务:then2 then1 log('async1 end')
宏任务:setTimeout
输出:a c e g //执行同步代码

接下来就是先执行微任务,再来到第二轮event-loop执行宏任务,因此输出为

输出:a c e g b f g d

好了,这道题已经明白了,主要就是awati如今的规则是自身同步,微任务化后面的代码

总结

其实js事件循环机制很简单,就是先执行同步代码,然后异步又分为微任务和宏任务,接着先执行微任务再执行宏任务,宏任务中又可以包含同步异步代码,然后继续按照这个规则循环执行。


求赞.jpg

如果觉得本文对你有帮助的话,可以给个免费的赞吗[doge]