JavaScript 高级深入浅出:async、await 与事件循环
介绍
本文是 JavaScript 高级深入浅出的第 17
篇,本文将会介绍 async、await 与事件循环
正文
1. async/await
1.1 async
async
关键词声明一个异步函数:
async
是asynchronous
的缩写,意为 异步、非同步sync
是synchronous
的缩写,意为 同步
async function foo() {}
const bar = async () => {}
class Baz {
async foo() {}
}
1.2 异步函数和普通函数的区别
返回值不同
async function foo() {
return 'foo'
}
// 异步函数的返回值一定是一个 Promise 实例
const p = foo()
p.then(res => {
console.log(res)
})
// 所以就可以返回一个 Promise 实例或者 thenable 了
async function bar() {
return {
then(resolve) {
resolve('bar')
},
}
}
async function baz() {
return new Promise(resolve => {
resolve('baz')
})
}
1.3 await
async
函数的另外一个特殊之处就是可以在它内部使用 await
关键字,普通函数是不可以的
await
后面可以是 Promise
实例 、异步函数
、thenable
、普通的值
async function foo() {
return new Promise(resolve => {
setTimeout(() => {
console.log('foo execute')
resolve()
}, 2000)
})
}
async function bar() {
// 在 foo 代码执行完毕前,后面的代码都不会执行
// 就像是所有的代码都是同步执行的
await foo()
console.log('bar execute')
}
bar()
如果 await
后面的 Promise 实例的状态是 rejected
,那么将会作为 await 异步函数的 rejected 的值
async function foo() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('foo execute')
reject('error')
}, 2000)
})
}
async function bar() {
await foo()
// 下面这行就不会执行了,因为上面的代码是 rejected 的
console.log('bar execute')
}
bar().catch(err => {
// reject 的值将会被传递到这里
console.log('err', err)
})
2. 浏览器的事件循环
2.1 进程与线程
进程和线程是操作系统中的两个概念:
进程(process)
:计算机已经运行的程序,是操作系统管理程序的一种方式线程(thread)
:操作系统能够运行运算调度的最小单位,通常情况下它包含在进程中
通俗的说:
- 进程:可以理解为启动一个应用程序,就会创建一个进程(或者多个进程)
- 线程:每一个进程当中,都会至少创建一个线程用来执行程序中的代码,这个线程叫做主线程
- 所以也可以说进程是线程的容器
2.2 操作系统的工作方式
操作系统是如何做到让多个进程同时工作的呢?
- 这是因为 CPU 的运算速度非常快,它可以在多个进程之间迅速切换
- 当我们进程中的线程获取到时间片时,就可以快速执行我们编写的代码
- 对于用户来说是感受不到这种切换的
如果你使用的 CPU 是单核 CPU ,那么在正常运行时,CPU 就会在多个进程中切换
2.3 浏览器中的 JS 线程
我们经常说 JS 是单线程的,但是 JS 的线程应该有自己的容器:浏览器进程或 Node 进程
而浏览器只有一个进程吗?
- 现代浏览器中(特别是 Chrome),每个页面都有自己的渲染进程,每个进程中又会有很多线程,例如就会有执行 JS 代码的线程。
JS 的代码是在一个线程中执行的:
- 也就是说在同一时间 JS 只能做一件事
- 如果这件事是十分耗时的,那么就会产生线程阻塞
所以真正耗时的操作,都不是由 JS 线程去执行的:
- 浏览器的每个进程都是多线程的,那么其他线程就可以来完成这个耗时操作
- 比如 网络请求、定时器 等等,我们只需要执行回调即可
2.4 浏览器的事件循环
如果在执行 JS 代码的过程中,出现了异步操作呢?
- 比如我们调用了
setTimeout
这个函数 - 这个函数会被放到调用栈中,执行会立即结束,并不会阻塞后续代码的执行
- 而计时的操作则是交给其他线程来执行,并在计时完成后,调用
setTimeout
传入的回调函数
因此我们就可以得知,一些异步的操作浏览器是如何处理的:
- 为了不阻塞线程,一些可能会出现回调的函数/代码将会被立即执行
- 而计时等等操作则是交给其他线程来操作的
- 浏览器在内部维护着一个队列(queue),保存了正在进程异步操作的函数,这个队列就是事件队列
- 最终结束后,将会调用相应的回调,例如
setTimeout
传入的 timer 参数 - 而这一过程,就是事件循环。
2.5 宏任务和微任务
在 2.4 中,我们知道了浏览器内部维护着一个事件队列,其实事件队列分为 宏任务队列(MarcotaskQueue 和 微任务队列(MircotaskQueue)
加入宏任务队列的操作:
- 定时器(setTimeout、setInterval)
- ajax
- DOM(点击事件等)
- UI 渲染操作
- ......
加入微任务队列的操作:
queueMircotask
Promise.then
MutationObserver
- ......
那么宏任务与微任务哪个会先执行呢?
- 规范:在执行任何宏任务之前,都要保证微任务队列已经被清空
- 也就是说在执行任何一个宏任务时,都要先执行微任务队列中的所有微任务
所以事件循环是这样的:
- 执行 main script(主代码)
- 将微任务队列中的任务加入 main script
- 将宏任务中的队列加入 main script(在执行每个宏任务时,都要确认微任务队列是否清空,若未清空,在执行该宏任务之前执行所有的微任务)
3. 浏览器事件循环面试题
接下来,我们通过面试题来巩固宏任务与微任务
3.1 面试题一
setTimeout(() => {
console.log('setTimeout1')
new Promise(resolve => {
resolve()
}).then(() => {
new Promise(resolve => {
resolve()
}).then(() => {
console.log('then4')
})
console.log('then2')
})
})
new Promise(resolve => {
console.log('promise1')
resolve()
}).then(() => {
console.log('then1')
})
setTimeout(() => {
console.log('setTimeout2')
})
console.log(2)
queueMicrotask(() => {
console.log('queueMicrotask1')
})
new Promise(resolve => {
resolve()
}).then(() => {
console.log('then3')
})
// 结果
// promise1 2 then1 queueMircotask1 then3 setTimeout1 then2 then4 setTiemout2
这道题还是很简单的,我们来看看解析:
- 同步代码开始:执行第 16 行的 new Promise ,执行 executor,打印
promise1
- 执行第 27 行的打印,打印
2
- 执行第 33 行的 new Promise,执行 executor,未有打印代码,同步代码结束
- 微任务队列开始清空:因为第 19 行的 Promise.then 是微任务,在执行第 1 行的 setTimeout 之前执行,打印
then1
- 因为第 29 行的 queueMicrotask 是微任务,因此打印
queueMicrotask1
- 因为第 36 行的 Promise.then 是微任务,在执行第 1 行的 setTimeout 之前执行,打印
then3
,微任务队列清空完毕 - 开始执行每个宏任务:执行第一行的 setTimeout 的 timer 回调
- 同步代码开始:执行第 2 行的打印,打印
setTimeout1
- 执行第 4 行的 new Promise,执行 executor,未有打印代码,同步代码结束
- 开始清空微任务队列:执行第 6 行的 Promise.then
- **同步代码开始:**执行第 7 行的 new Promsie,执行 executor,未有打印代码
- 执行第 12 行的打印,打印
then2
,同步代码结束 - 开始清空微任务队列:执行第 9 行的 Promise.then,打印
then4
,微任务队列结束 - 开始执行宏任务:执行第 23 行的宏任务,打印
setTimeout2
- 宏任务队列清空完毕,代码执行完毕
3.2 面试题二
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('script start')
setTimeout(() => {
console.log('setTimeout')
}, 0)
async1()
new Promise(resolve => {
console.log('promise1')
resolve()
}).then(() => {
console.log('promise2')
})
// 结果
// script start async1 start async2 promise1 async1 end promise2 setTimeout
在看解析之前,我们先看一个知识点:
async function foo() {
console.log('foo')
}
async function bar() {
console.log('bar start')
await foo()
console.log('bar end')
}
bar()
console.log('main script end')
// 此时这里的 bar end 其实是一个微任务
// 因为在 async/await 的时候我们已经说了,异步任务一定会返回一个 Promise,而
// await foo() 后面的代码要想在执行 foo 函数后面执行,那么就会加入在 foo 返回的 Promise
// 的 then 里面的
// 相当于
async function foo() {
console.log('foo')
return new Promise(resolve => {
resolve()
}).then(() => {
console.log('bar end')
})
}
只要了解了这个知识点,那么我们再看面试题还是简单的,看解析:
- 同步代码开始:打印 11 行的
script start
- 执行 async1 函数,打印第 2 行的
async1 start
- 执行 async2 函数,打印第 8 行的
async2
- 执行第 19 行的 new Promise 的 executor,打印
promise1
,同步代码结束 - 开始清空微任务:执行第 4 行,打印
async1 end
- 执行第 23 行,打印
promise2
,微任务队列清空完毕 - 开始清空宏任务:执行第 13 行的 setTimeout 传入的回调,打印
setTimeout
,宏任务清空完毕 - 代码执行完毕
3.3 面试题三
Promise.resolve()
.then(() => {
console.log(0)
return Promise.resolve(4)
})
.then(res => {
console.log(res)
})
Promise.resolve()
.then(() => {
console.log(1)
})
.then(() => {
console.log(2)
})
.then(() => {
console.log(3)
})
.then(() => {
console.log(5)
})
.then(() => {
console.log(6)
})
这个执行顺序很奇怪,但是我们改一下代码:
Promise.resolve()
.then(() => {
console.log(0)
// 这里改了
return 4
// 相当于 resolve(4)
})
.then(res => {
console.log(res)
})
// 剩下的代码没动
// 就很容易推断出来结果是:
// 0 1 4 2 3 5 6
接下来,我们再改一下:
Promise.resolve()
.then(() => {
console.log(0)
// 这里改了
return {
then(resolve) {
resolve(4)
}
}
})
.then(res => {
console.log(res)
})
// 剩下的代码没动
// 这里的结果又变成了
// 0 1 2 4 3 5 6
为什么呢???这是因为执行 then
函数本身被放在了下一次的微任务中(在 then 中若返回的不是一个普通的值,例如返回了 thenable 或 Promise,那么就会被延迟到下一次微任务执行)。所以不难理解,由于本身 then 就推迟了一次微任务,因此 4 的顺序就又往后延了一个
最后让我们回到最开始,由于在 then 的时候 return 了一个非普通的值即 Promise.resolve(4)
,延迟了一次微任务,由于 Promise.resolve(4) 又延迟了一次微任务,因此一共延迟了两个微任务。
所以最终会执行 0 1 2 3 4 5 6
实际上,上述的操作在 Promises/A+ 中并没有类似的规范,因此在做类似的面试题时还是要实际操作一下。
为什么会有推迟这个操作呢?如果返回的是一个普通的值,那么就不会有复杂的情况,但是如果返回的是 thenable 或者 Promise,那么可能此函数中存在有大量的计算,立刻执行就有可能会阻塞下面的微任务的执行,因此就将其推迟到了下一次的微任务中。
4. Node 的事件循环
浏览器中的 EventLoop 是根据 HTML5 定义的规范来实现的,不同的浏览器可能有不同的实现,而 Node 的实现循环是根据 libuv 这个库所实现的。
这里我们看一下 Node.js 的架构图:
- 我们发现 libuv 主要是维护了 EventLoop 和 worker threds(线程池)
- EventLoop 负责调用系统的一些其他操作:文件的 IO、Network、child-processes 等
libuv 是一个跨平台的专注于异步 IO 的库,最初为 Node.js 开发,后面也被 Luvit、Julia、pyuv 等用在很多地方
4.1 Node 事件循环的阶段
事件循环就像是一个桥梁,是连接着应用程序的 JavaScript 和系统调用之间的通道:
- 无论是我们的文件 IO、数据库、网络 IO、定时器、子进程,在完成相应的操作后,都会将对应的结果和回调函数放到事件循环(任务队列)中
- 事件循环会不断的从**任务队列中取出对应的事件(回调函数)**来执行
一次完整的事件循环 Tick 分为很多个阶段:
- 定时器(Timers):本阶段执行已经被
setTimeout()
和setIterval()
的调度回调函数 - 待定回调(Pending Callback):对某些系统操作(如 TCP 错误类型)执行回调,比如 TCP 连接时接收到 ECONNREFUSED
- idle, prepare:仅系统内部使用
- 轮询(Poll):检索新的 I/O 事件,执行与 I/O 相关的回调
- 检测(check):
setImmediate()
回调函数在这里执行 - 关闭的回调函数:一些关闭的回调函数,如
socket.on('close', ...)
4.2 Node 的宏任务与微任务
在 Node 的一次事件循环的 Tick 中我们发现,Node 的事件循环会更加复杂,在其实在 Node 中也分为宏任务与微任务:
- 宏任务(macrotask):
setTimeout
、setInterval
、IO 事件
、setImmediate
、close 事件
- 微任务(microtask):
Promise then 回调
、process.nextTick
、queueMicrotask
但是 Node 中的事件循环不仅仅是为任务队列和宏任务队列:
- 微任务队列:
next tick queue
:process.nextTickother queue
:Promise.then 回调、queueMicrotask
- 宏任务队列:
timer queue
:setTimeout、setIntervalpoll queue
:IO 事件check queue
:setImmediateclose queue
:close 事件
所以在每一次的事件循环的 tick 中,其实是按照下面的顺序执行的:
- next tick mircotask queue
- other microtask queue
- timer queue
- poll queue
- check queue
- close queue
5. Node 事件循环面试题
5.1 面试题一
async function async1() {
console.log('async 1 start')
await async2()
console.log('async 1 end')
}
async function async2() {
console.log('async2')
}
console.log('script start')
setTimeout(() => {
console.log('setTimeout0')
}, 0)
setTimeout(() => {
console.log('setTimeout2')
}, 300)
setImmediate(() => console.log('setImmediate'))
process.nextTick(() => console.log('nextTick1'))
async1()
process.nextTick(() => console.log('nextTick2'))
new Promise(resolve => {
console.log('promise1')
resolve()
console.log('promise2')
}).then(() => {
console.log('promise3')
})
console.log('script end')
// 执行顺序
// script start
// async 1 start
// async2
// promise1
// promise2
// script end
// nextTick1
// nextTick2
// async 1 end
// promise3
// setTimeout0
// setImmediate
// setTimeout2
解析
根据解析我们发现还是不难的:
- 首先执行 main script:执行第 11 行,打印
script start
- 执行 25 行的 async 1,执行第 2 行,打印
async 1 start
- await 执行第 3 行的 async2,执行第 8 行,打印
async 2
,而第 4 行的代码相当于是 Promise.then 的回调,所以现在不执行 - 执行第 30 行打印
promise1
,执行第 32 行打印promise2
- 执行第 37 行打印
script end
,main script 完毕 - 开始清空微任务队列:根据 4.2 中的顺序,执行 23 行,打印
nextTick1
- 执行第 27 行,打印
nextTick2
- 执行第 4 行,打印
async1 end
- 执行第 34 行,打印
promise3
,微任务完毕 - 开始清空宏任务队列:执行第 17 行的定时器,打印
setTimeout0
- 执行第 21 行,打印
setImmediate
,宏任务完毕 - 等待第 17 行的定时器完毕后,执行回调函数,再次执行新一轮的宏任务,打印
setTimeout2
,宏任务完毕
总结
本篇重点介绍了浏览器与 Node 环境中的事件循环,我们知道了什么是宏任务与微任务,也知道了浏览器环境与 Node 环境下的宏任务与微任务的不同,通过一组面试题,巩固了任务队列的执行顺序
转载自:https://juejin.cn/post/7067780709548720136