一道很简单的 event loop 面试题
console.log('script start');
async function async1() {
await async2();
console.log('async1 end after await');
}
async function async2() {
console.log('async2 end');
}
async1();
new Promise(resolve => {
console.log('Promise');
resolve();
}).then(function() {
console.log('promise.then 1');
}).then(function() {
console.log('promise.then 2');
})
这道题看起来很简单,但是其中隐含了一个极度迷惑的点,究竟是 async1 end after await 先执行还是 promise.then 1 先执行?看起来好像都属于 Promise 这一趴的微任务,那么先进先出对不对?
答案是错误的,这个跟 node 版本有关系,确切的说跟 v8 版本是有关系的,具体 node 版本表现如下
- 8 ~ 9: await 后面的打印先执行,其中 7 中并没有完全支持 async 方法,这里暂且不做讨论。
- 10 ~ 11: promise.then 先执行。
- 12+: await 后面的打印先执行。
为什么会这样呢? v8做了什么手脚?
v8 从 Node.js 8(V8 v6.2/Chrome 62)开始,就完全支持异步函数(Async),从 Node.js 10(V8 v6.8/Chrome 68)开始,就完全支持异步迭代器(Async iterators)和生成器(Generators)!
性能优化
在 V8 v5.5(chrome55 & Node.js 7)和 V8 v6.8(chrome68 & Node.js 10)之间,v8 已经成功地提高了异步代码的性能,开发人员可以安全地使用这些新的编程范例,而不必担心速度问题。
异步代码执行时间测试:
并行异步代码测试(Promise.all):
可以看到,并行的异步代码,在新的 node 版本上性能提升了 8 倍之多。
然而,上述测试都是在单独测试的异步代码,那么在实际项目中表现如何呢?这里,v8 官方公布了在不同 node 框架上的性能提升对比。
不同框架下,每秒请求量统计:
上面的图表可视化了一些流行的 HTTP 中间件框架的性能,这些框架大量使用了 promises 和 async 函数。请注意,这个图表显示的是请求/秒的数量,因此与前面的图表不同,越高越好。在 Node.js 7(V8 v5.5)和 Node.js 10(V8 v6.8)之间,这些框架的性能有了显著提高。
这些性能的提升来自三个地方:
- 编译器的优化 the new optimizing compiler 🎉
- 新的垃圾回收机制 the new garbage collector 🚛
- a Node.js 8 bug causing await to skip microticks
注意第三点,Node 8 存在一个 bug,它会使 await 后面的代码跳过微任务,直接导致 await 后的代码先于 promise.then 执行,也就是前面测试得到的结果, 此 bug 在 Node 10 中被修复,并于 Node 12 中给予 v8 开发团队启发, 做了类似的优化。
让我们看下 v8 引擎对 await 做了什么
这里有一个简单的 async 函数 foo:
async function foo(v) {
const w = await v;
return w;
}
大体上,我们可以认为,当调用时,它将参数 v 封装到一个 promise 中,并挂起 async 函数的执行,直到这个 promise 被解析。一旦发生这种情况,函数的执行将继续,并为 w 分配 返回的 promise 对象。然后从 async 函数返回这个值。
让我们来看一些 v8 更细节的实现流程,这也是我们今天主要讨论的内容(这里是 node 10的实现),如图所示:
- 首先把 v8 把函数 foo 标记成可恢复的(resumable), 这意味着执行可以暂停并在稍后恢复。 然后它创建所谓的 implicit_promise,这是在调用 async 函数时返回的 promise 对象,并最终解析为 async 函数返回的值,这点很类似 new 调用函数,创建一个空对象一样。
- 又创建一个 promise,然后包裹传入的值 v,也就是说把传入的值包装成了一个 promise。
- 再次创建 promise,也就是 throwway,去把这个附加到包装后的值上面,也就是说,当包装成 promise 后的 v 执行的时候(pending),此时 throwway 负责暂停了 foo 函数的执行,并将 implicit_promise 返回给调用者,一旦包装后的 v 得到成功的结果(fulfilled), 首先会恢复 foo 函数的执行,然后将返回值赋给 implicit_promise,再转交给 w 来表示。
三次创建 promise 就是我们性能开销的主要来源,特别是如果输入的 v 已经是一个 promise 对象,这是对性能的极大浪费,我们是否判断它本身是不是一个 promise 再决定是否创建新的 promise 来包装它呢。
事实上,新版规范中已经有一个 promiseResolve 操作,只在需要时执行包装:
我们可以看到,如果 v 已经是一个 promise 对象的话,就省略了一次包装 v 的构建,其次又复用了 implicit_promise(取代掉累赘的 throwway),从三次构建 promise 到 一次构建,执行速度提升了三倍,在这种情况下,我们将从最少3微秒变为仅仅1微秒。这种行为类似于 Node.js 8所做的,只不过现在它不再是一个 bug ーー 它现在是一个标准化的优化!
这个新行为在 V8 v7.2 (node v12)中已经默认启用。对于 V8 v7.1,可以使用 -- harmony-await-optimization 标志启用新行为。
比较 Node.js 10中的 await 和 Node.js 12中可能出现的优化 await,可以看出这一变化对性能的影响:
Async/await 现在比手写的承诺代码性能更好。这里的关键要点是,我们通过修补规范,大大降低了异步函数的开销——不仅在 V8中,而且在所有 JavaScript 引擎中都是如此。
在 V8 v7.2和 chrome72中,默认启用了 harmony-await 优化。ECMAScript 规范的补丁被合并了。
你明白了么?
参考资料: v8 blog v8 如何执行一段 JS 代码