vlambda博客
学习文章列表

简单聊一聊浏览器下的 eventloop, 宏任务与微任务

函数调用栈

函数都是有调用栈的, 大多数程序可以看成函数调用函数不断调用函数, 比如下面这张图:

func_stack

可以看到, 首先有一个 main(可以看做是入口), 然后依次是fn3()调用fn2(), 再调用fn1(), 最后调用console.log(). 一般出现错误的时候, 就会出现类似栈调用情况, 如下:

简单聊一聊浏览器下的 eventloop, 宏任务与微任务

callstack_error


event loop

首先推荐观看这个视频What the heck is the event loop anyway?[1], 以及作者写了一个 event loop 可视化工具: Loupe[2], 不过目前这个工具支持比较有限, 不支持查看 callback queue 里面具体的 macrotask 和 microtask.

这里还是总结一下基本的 event loop 流程, 首先看这张图:

简单聊一聊浏览器下的 eventloop, 宏任务与微任务

event_loop_overview


首先 JavaScript 是一门单线程语言, 只有一个主线程, 你可以看成 main, 该线程负责调用函数堆栈, 也就是前面所说的Call Stack其中有一些 API 浏览器是不提供, 所以无法处理, 主线程会把这些 API, 放到另外一个地方(绿色部分)处理, 这些 API 叫作 Web API(也可以称之为调度者), 如上图中的 setTimeoutsetTimeIntervalXMLHttpRequest等等这里注意, 在处理这些 Web API 的时候, js 引擎的主线程还是在不断工作的(如果有任务的话), 可以认为两边是互相工作, 互不干扰回到 WebAPI 部分, 假设放到 Web API 部分的这段代码是 setTimeout((_) => console.log(1), 1000), 这里绿色部分所做的是:等 1000ms(_) => console.log(1)这个回调放到 Event Queue(事件队列, 蓝色部分) 里面, 这里注意: 并非是把setTimeout()整个函数放入到事件队列里面, 仅放入其中的回调函数部分也就是说, 调度者在上下文代码中还是同步立即执行, 只不过其随后会将自己里面的回调函数放入任务队列中等待执行.最后对于 Event Queue 部分, 这里会堆积很多 Web API 移动下来的回调函数. 当 js 引擎的 call stack 为空的时候, event queue 会把最先进入到 queue 里面的回调函数推入 call stack 里面执行

给一张图动态描述上述的过程:

event_loop

几个注意点:

web api 执行 (setTimeout, 请求等), 一旦时间到, 立马就将里面的回调函数放入到 event queue 任务队列中一旦主线程空了, 那么不管 web api 里面是否还有代码在运行, 都会开始执行任务队列里面的任务

所以整个执行过程是相当连贯协调, 大家分工合作, 每个函数都有自己的职责和暂时归属地, 属于哪里以及什么时候被执行都是井井有条. 避免了同步情况下, 一个无关紧要的任务被卡死, 其余任务无法被执行的现象

异步任务和同步任务

同步任务: JS 引擎主线程里面立即被推入 call stack 且可以被执行的函数异步任务: 在 event queue 里面的回调函数, 也就是独立于主线程里面的任务

举一个例子:

下面的这个片段, synCb这个回调函数是不会被推入到 event queue 里面执行的, 会被浏览器当做同步任务执行, 所以打印的结果就是按照从上到下面的顺序, 依次进入堆栈输出的

console.log('start')
const syncFun = function(synCb) { const v = 100 synCb(v)}syncFun(v => console.log(v))console.log('end')
// start// 100// end

假如我们加入异步任务, 如下, 那么这里的asynCb是会作为异步任务放入 event queue 里面等主线程清空以后(也就是两个 console.log都执行完毕), 才会被推入 call stack 被调用

console.log('start')
setTimeout(function asynCb() { console.log(100)}, 0)
console.log('end')
// start// end// 100

总的来说在这种情况下, js 形成了两个队列: 同步任务队列和异步任务队列, 先执行同步任务队列里面的任务, 所有同步任务执行完毕以后, 再执行异步任务队列里面的任务.

异步回调

普通函数回调

例如setTimeout(callback, timer), 里面的 callback 是异步任务, 最后是会被放入到 event queue 里面的, 这里要注意一个setTimeout的点: 即里面的时间并非真正意义上的执行时间, 考虑如下代码:

setTimeout(() => { console.log(8)}, 5000)
setTimeout(() => { console.log(9)}, 5000)
setTimeout(() => { console.log(10)}, 5000)
// 8// 9// 10

上述代码最后会依次打印 8, 9, 10. 这是由于根据执行顺序会依次放入到 event queue 里面, 也就是说, 打印 8 的回调会被先放入到异步任务队列里面, 然后是 9, 10.

所以这里的时间并非指的是这个函数的执行时间, 而指代的是, 异步回调函数几秒后会被放入到任务队列

Promise 和 async/await

Promise

promise中, .then()里面的为异步回调

假设有如下代码:

console.log('start')
new Promise((resolve, reject) => { resolve(1) console.log('middle')}).then(v => console.log(v))
console.log('end')
// start// middle// end// 1

上面代码中, v => console.log(v)是异步回调, 注意, new Promise在实例化的过程中所执行的代码都是同步进行的, 所以console.log('middle')会同步执行

async/await

async/await 本质上还是基于 Promise 的一些封装, async函数在await 之前的代码都是同步执行的,可以理解为await之前的代码属于new Promise时传入的代码,await 之后的所有代码都是在Promise.then中的回调

console.log('start')
async function main() { console.log('test') const v = await Promise.resolve(1) console.log(v)}
main()console.log('end')
// start// test// end// 1

写法总结

使用request[3], 写一个getUser函数, 有基本以下写法:

回调写法:

const getUser = function(callback) { request('http://www.example.com', function(err, res, body){ callback(body) })}
getUser(v => console.log(v))

Promise 写法:

const getUser = function() { return new Promise((resolve, reject) => { request('http://www.example.com', function(err, res, body){ if (err) { return reject(err) } resolve(body) }) })}
getUser() .then(v => console.log(v)) .catch(err => console.error(err))

async/await 写法:

const getUser = function() { return new Promise((resolve, reject) => { request('http://www.example.com', function(err, res, body){ if (err) { return reject(err) } resolve(body) }) })}
(async function() { try { const v = await getUser() console.log(v) } catch(e) { console.error(e) }})()

宏任务与微任务

上面提到的 event queue 里的异步任务还可以细分为: macrotask(宏任务), microtask(微任务). 这里注意, 现在标准称呼可以认为是 tasks 和 jobs, 但本文还是以宏任务和微任务来代指

宏任务:

script(整体代码, 上下文)setTimeoutsetIntervalI/OUI rendering

微任务:

Promiseprocess.nextTick

当然上面的任务其实均指的是其中的回调函数, 而上面的 api 前面也提过, 充当调度者的作用

有几个概念需要注意一下:

promise.then(callback)里面的callback是一个微任务, 且会被推入当前的微任务队列, 当且仅当该promise状态变更为resolved或者rejected, 否则不被推入任务队列setTimeout(callback, t)callback是一个宏任务, 会被推入当前的宏任务队列中, 即使t为 0整个在 script 中的代码也是一个宏任务

模型

关于宏任务和微任务的模型可以看成如下图的形式:

micro_macrotasks

有几个重要的概念:

始终只有一条宏任务队列可以有多条微任务队列, 但是每一个宏任务后面仅跟随一条微任务队列, 且只对当前的宏任务"有效"一次 loop(循环), 都会去执行宏任务里面最前面的一个宏任务, 然后检查是否有微任务队列, 依次执行微任务队列里面的微任务. 执行完毕后开始下一次循环, 和之前一样从宏任务队列里面选取最新的宏任务执行, 不断循环在执行微任务/宏任务的过程中, 如果发现微任务, 那么添加到当前的微任务队列中等待稍后被执行, 而如果发现宏任务, 该宏任务被添加到宏任务队列中等待下一次 loop 被执行. 也就是说: 发现微任务是可以在当前 loop 下被执行的, 而发现的宏任务只能等到下一次循环的时候被执行每一次 loop 都包含: 一个(且只有一个)宏任务被执行, 以及对应的微任务队列, 执行完毕后开启下一个 loop

总结:

1.运行宏任务队列最先进来的宏任务, 然后移除他(一般第一个宏任务是 script)2.依次运行微任务队列中的微任务, 移除他们3.开始下一次 loop(返回过程 1)

异步任务模型伪代码可以模拟成如下:

// 第一次 loop[ ['宏任务 1', '微任务 1.1', ' 微任务 1.2'], ['宏任务 2'], ['宏任务 3'],
]
// 第二次 loop[ // ['宏任务 1', '微任务 1.1', ' 微任务 1.2'], 执行完毕被清空 ['宏任务 2', '微任务 2.1'], ['宏任务 3'],]
// 第三次 loop[ // ['宏任务 1', '微任务 1.1', ' 微任务 1.2'], 执行完毕被清空 // ['宏任务 2'], 执行完毕被清空 ['宏任务 3'],]

示例

示例 1:

console.log('start')
setTimeout(function() { console.log('timeout')}, 0)
new Promise(function(resolve) { console.log('promise') resolve()}).then(function() { console.log('promise resolved')})
console.log('end')
// start// promise// end// promise resolved// timeout

分析:

1.建立执行上下文,进入执行栈开始执行代码,打印 start2.往下执行,遇到setTimeout,将回调函数放入宏任务队列,等待执行3.继续往下,有个new Promise,其回调函数并不会被放入其他任务队列,因此会同步地执行,打印promise,但是当resolve后,.then会把其内部的回调函数放入微任务队列4.执行到了最底部的代码,打印出 end, 此时 call stack 被清空, 可以执行异步任务5.开始第一次 event loop:1.由于整个 script 算一个宏任务, 因此该宏任务已经被执行完毕2.检查微任务队列, 发现其中有 3 放入的微任务, 执行打印出 promise resolved,第一次循环结束6.开始第二次 loop:1.从宏任务开始,检查宏任务队列是否有可执行代码,发现有 2 中放入的一个,打印timeout2.检查微任务队列, 微任务, 第二次循环结束

示例 2:

console.log('start')
setTimeout(function () { console.log('event loop2, macrotask') new Promise(function (resolve) { console.log('event loop2, macrotask continue') resolve() }).then(function () { console.log('event loop2, microtask1') })}, 0)
new Promise(function (resolve) { console.log('middle') resolve()}).then(function () { console.log('event loop1, microtask1') setTimeout(function () { console.log('event loop3, macrotask') })})
console.log('end')
// start// middle// end// event loop1, microtask1// event loop2, macrotask// event loop2, macrotask continue// event loop2, microtask1// event loop3, macrotask

分析:

1.打印 start, 发现 setTimeout, 回调放入宏任务队列中2.发现在初始化 promise 实例, 初始化过程中打印 middle, 初始化完毕后 promise 状态变为 resolve3.resolve 后因为后面的.then()将回调放当前入微任务队列中4.打印 end, script 完成5.第一次 event loop:1.由于 script 完成, 相当于完成了宏任务2.检查微任务队列, 发现有一个在第 3 步放入的微任务, 执行打印 event loop1, microtask1, 继续执行, 发现有setTimeout, 将其中回调放入宏任务队列中6.第二次 event loop:1.检查宏任务队列, 发现排在最前面的是第 1 步放入的宏任务, 执行打印 event loop2, macrotask2.继续执行, 发现在初始化 promise 实例, 打印 event loop2, macrotask continue3.promise 在状态被 resolve 之后回调放入当前微任务队列中4.检查微任务队列, 发现有一个在第 6.3 中的微任务, 执行打印 event loop2, microtask17.第三次 event loop:1.发现在第 5.2 步中放入的最后一个宏任务, 执行并打印 event loop3, macrotask

一个相关问题

前两天在知乎看到的一个问题: JavaScript的DOM事件回调不是宏任务吗,为什么在本次微任务队列触发[4]

console.log('本轮任务');new Promise((resolve, reject) => { resolve(3)}).then(() => { console.log('本轮微任务');})document.getElementById('div').addEventListener('click', () => { console.log('click'); })document.getElementById('div').click()
// 本轮任务// click// 本轮微任务

这里注意: click()dispatchEvent()等人工合成事件是同步触发的, 其回调并非会被放入宏任务队列中, 而是直接作为同步任务执行, 具体答案可以参考这个回答[5] 和补充[6]

参考

https://zhuanlan.zhihu.com/p/75572565https://juejin.im/post/5b73d7a6518825610072b42bhttps://blog.sessionstack.com/how-javascript-works-event-loop-and-the-rise-of-async-programming-5-ways-to-better-coding-with-2f077c4438b5https://stackoverflow.com/questions/25915634/difference-between-microtask-and-macrotask-within-an-event-loop-contexthttps://jakearchibald.com/2015/tasks-microtashttps://www.zhihu.com/question/362096226/answer/944729236

引用链接

[1] What the heck is the event loop anyway?: https://www.youtube.com/watch?v=8aGhZQkoFbQ&t=1219s
[2] Loupe: http://latentflip.com/loupe
[3] request: https://github.com/request/request
[4] JavaScript的DOM事件回调不是宏任务吗,为什么在本次微任务队列触发: https://www.zhihu.com/question/362096226
[5] 回答: https://www.zhihu.com/question/362096226/answer/944729236
[6] 补充: https://www.zhihu.com/question/362096226/answer/999973223