从一道题浅聊await v8实现

110 阅读5分钟

一道很简单的 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)之间,这些框架的性能有了显著提高。

这些性能的提升来自三个地方:

  1. 编译器的优化 the new optimizing compiler 🎉
  2. 新的垃圾回收机制 the new garbage collector 🚛
  3. 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的实现),如图所示:

  1. 首先把 v8 把函数 foo 标记成可恢复的(resumable), 这意味着执行可以暂停并在稍后恢复。 然后它创建所谓的 implicit_promise,这是在调用 async 函数时返回的 promise 对象,并最终解析为 async 函数返回的值,这点很类似 new 调用函数,创建一个空对象一样。
  2. 又创建一个 promise,然后包裹传入的值 v,也就是说把传入的值包装成了一个 promise。
  3. 再次创建 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 代码