阅读 1164

V8 中更快的异步函数和 promises


原文作者:Maya Lekova and Benedikt Meurer

译者:UC 国际研发 Jothy


写在最前:欢迎你来到“UC国际技术”公众号,我们将为大家提供与客户端、服务端、算法、测试、数据、前端等相关的高质量技术文章,不限于原创与翻译。


一直以来,JavaScript 的异步处理都因其速度不够快而名声在外。 更糟糕的是,调试实时 JavaScript 应用 - 特别是 Node.js 服务器 - 并非易事,特别是在涉及异步编程时。 幸好,这些正在发生改变。 本文探讨了我们如何在 V8(某种程度上也包括其他 JavaScript 引擎)中优化异步函数和 promise,并描述了我们如何提升异步代码的调试体验。

注意:如果你喜欢边看演讲边看文章,请欣赏下面的视频!如果不是,请跳过视频并继续阅读。

视频地址:

https://www.youtube.com/watch?v=DFP5DKDQfOc



一种新的异步编程方法


>> 从回调(callback)到 promise 再到异步函数 <<

在 JavaScript 还没实现 promise 之前,要解决异步的问题通常都得基于回调,尤其是在 Node.js 中。 举个例子🌰:

我们通常把这种使用深度嵌套回调的模式称为“回调地狱”,因为这种代码不易读取且难以维护。

所幸,现在 promise 已成为 JavaScript 的一部分,我们可以以一种更优雅和可维护的方式实现代码:

最近,JavaScript 还增加了对异步函数的支持。 我们现在可以用近似同步代码的方式实现上述异步代码:

使用异步函数后,虽然代码的执行仍然是异步的,但代码变得更加简洁,并且更易实现控制和数据流。(请注意,JavaScript 仍在单线程中执行,也就是说异步方法本身并没有创建物理线程。)


>> 从事件监听回调到异步迭代 <<

另一个在 Node.js 中特别常见的异步范式是 ReadableStreams。 请看例子:

这段代码有点难理解:传入的数据只能在回调代码块中处理,并且流 end 的信号也在回调内触发。 如果你没有意识到函数会立即终止,且得等到回调被触发才会进行实际处理,就很容易在这里写出 bug。


幸好,ES2018 的一项新的炫酷 feature——异步迭代,可以简化此代码:


我们不再将处理实际请求的逻辑放入两个不同的回调 - 'data' 和 ' end ' 回调中,相反,我们现在可以将所有内容放入单个异步函数中,并使用新的 for await...of 循环实现异步迭代了。 我们还添加了 try-catch 代码块以避免 unhandledRejection 问题[1]


你现在已经可以正式使用这些新功能了! Node.js 8(V8 v6.2/Chrome 62)及以上版本已完全支持异步方法,而 Node.js 10(V8 v6.8/Chrome 68)及以上版本已完全支持异步迭代器(iterator)和生成器(generator)!



异步性能提升

我们已经在 V8 v5.5(Chrome 55 和 Node.js 7)和 V8 v6.8(Chrome 68 和 Node.js 10)之间的版本显着提升了异步代码的性能。开发者可安全地使用新的编程范例,无需担心速度问题。


上图显示了 doxbee 的基准测试,它测量了大量使用 promise 代码的性能。 注意图表展示的是执行时间,意味着值越低越好。

并行基准测试的结果,特别强调了 Promise.all() 的性能,更令人兴奋:

我们将 Promise.all 的性能提高了 8 倍!

但是,上述基准测试是合成微基准测试。 V8 团队对该优化如何影响真实用户代码的实际性能更感兴趣。

上面的图表显示了一些流行的 HTTP 中间件框架的性能,这些框架大量使用了 promises 和异步函数。 注意此图表显示的是每秒请求数,因此与之前的图表不同,数值越高越好。 这些框架的性能在 Node.js 7(V8 v5.5)和 Node.js 10(V8 v6.8)之间的版本得到了显着提升。


这些性能改进产出了三项关键成就:

  • TurboFan,新的优化编译器 🎉

  • Orinoco,新的垃圾回收器 🚛

  • 一个导致 await 跳过 microticks 的 Node.js 8 bug 🐛


在 Node.js 8 中启用 TurboFan 后,我们的性能得到了全面提升。

我们一直在研究一款名为 Orinoco 的新垃圾回收器,它可以从主线程中剥离出垃圾回收工作,从而显著改善请求处理。

最后亦不得不提的是,Node.js 8 中有一个简单的错误导致 await 在某些情况下跳过了 microticks,从而产生了更好的性能。 该错误始于无意的违背规范,但却给了我们优化的点子。 让我们从解释该 bug 开始:

上面的程序创建了一个 fulfilled 的 promise p,并 await 其结果,但也给它绑了两个 handler。 你希望 console.log 调用以哪种顺序执行呢?


由于 p 已经 fulfilled,你可能希望它先打印 'after: await' 然后打 'tick'。 实际上,Node.js 8 会这样执行:


在Node.js 8 中 await bug


虽然这种行为看起来很直观,但按照规范的规定,它并不正确。 Node.js 10 实现了正确的行为,即先执行链式处理程序,然后继续执行异步函数。

Node.js 10 没有 await bug

这种“正确的行为”可以说并不是很明显,也挺令 JavaScript 开发者大吃一惊 🐳,所以我们得解释解释。 在我们深入 promise 和异步函数的奇妙世界之前,我们先了解一些基础。



>> Task VS Microtask <<

JavaScript 中有 task 和 microtask 的概念。 Task 处理 I/O 和计时器等事件,一次执行一个。 Microtask 为 async/await 和 promise 实现延迟执行,并在每个任务结束时执行。 总是等到 microtasks 队列被清空,事件循环执行才会返回。


task 和 microtask 的区别


详情请查看 Jake Archibald 对浏览器中 task,microtask,queue 和 schedule 的解释。 Node.js 中的任务模型与之非常相似。


文章地址:

https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/


>> 异步函数<<

MDN 对异步函数的解释是,一个使用隐式 promise 进行异步操作并返回其结果的函数。 异步函数旨在使异步代码看起来像同步代码,为开发者降低异步处理的复杂性。


最简单的异步函数如下所示:

当被调用时,它返回一个 promise,你可以像调用别的 promise 那样获得它的值。

只有在下次运行 microtask 时才能获得此 promise 的值。 换句话说,以上程序语义上等同于使用 Promise.resolve 获取 value:

异步函数的真正威力来自 await 表达式,它使函数执行暂停,直到 promise 完成之后,再恢复函数执行。 await 的值是 promise fulfilled(完成)的结果。 这个示例可以很好地解释:

fetchStatus 在 await 处暂停,在 fetch promise 完成时恢复。 这或多或少等同于将 handler 链接到 fetch 返回的 promise。

该 handler 包含 async 函数中 await 之后的代码。


一般来说你会 await 一个 Promise,但其实你可以 await 任意的 JavaScript 值。 就算 await 之后的表达式不是 promise,它也会被转换为 promise。 这意味着只要你想,你也可以 await 42:

更有趣的是,await 适用于任何 “thenable”,即任何带有 then 方法的对象,即使它不是真正的 promise。 因此,你可以用它做一些有趣的事情,例如测量实际睡眠时间的异步睡眠:

让我们按照规范看看 V8 引擎对 await 做了什么。 这是一个简单的异步函数 foo:

当 foo 被调用时,它将参数 v 包装到一个 promise 中,并暂停异步函数的执行,直到该 promise 完成。完成之后,函数的执行将恢复,w 将被赋予 promise 完成时的值。 然后异步函数返回此值。


>> V8 如何处理 await <<

首先,V8 将该函数标记为可恢复,这意味着该操作可以暂停并稍后恢复(await 时)。 然后它创建一个叫 implicit_promise 的东西,这是在调用异步函数时返回的 promise,并最终 resolve 为 async 函数的返回值。

简单的异步函数以及引擎解析结果对比


有趣的地方在于:实际的 await。首先,传递给 await 的值会被封装到 promise 中。然后,在 promise 后带上 handler 处理函数(以便在 promise 完成后恢复异步函数),而异步函数的执行会被挂起,将 implicit_promise 返回给调用者。一旦 promise 完成,其生成的值 w 会返回给异步函数,异步函数恢复执行,w 也即是 implicit_promise 的完成(resolved)结果。


简而言之,await v 的初始步骤是:

1. 封装 v - 传递给 await 的值 - 转换为 promise。

2. 将处理程序附加到 promise 上,以便稍后恢复异步函数。

3. 挂起异步函数并将 implicit_promise 返回给调用者。


让我们一步步来完成操作。假设正在 await 的已经是一个已完成且会返回 42 的 promise。然后引擎创建了一个新的 promise 并完成了 await 操作。这确实推迟了这些 promise 下一轮的链接,正如 PromiseResolveThenableJob 规范表述的那样。


然后引擎创造了另一个叫 throwaway(一次性)的 promise。 之所以被称为一次性,是因为它不会由任何链式绑定 - 它完全存在引擎内部。 然后 throwaway 会被链接到 promise 上,使用适当的处理程序来恢复异步函数。 这个 performPromiseThen 操作是 Promise.prototype.then() 隐式执行的。 最后,异步函数的执行会暂停,并将控制权返回给调用者。


调用程序会继续执行,直到调用栈为空。 然后 JavaScript 引擎开始运行 microtask:它会先运行之前的 PromiseResolveThenableJob,生成新的 PromiseReactionJob 以将 promise 链接到传递给 await 的值。 然后,引擎返回处理 microtask 队列,因为在继续主事件循环之前必须清空 microtask 队列。


接下来是 PromiseReactionJob,它用我们 await 的 promise 返回的值 - 此时是 42 - 完成了 promise,并将该反应处理到 throwaway 上。 然后引擎再次返回 microtask 循环,循环中是最终待处理的 microtask。



接着,第二个 PromiseReactionJob 将结果传递回 throwaway promise,并恢复暂停执行的异步函数,从 await 返回值 42。


await 的开销

总结以上所学,对于每个 await,引擎都必须创建两个额外的 promise(即使右边的表达式已经是 promise)并且它需要至少三个 microtask 队列执行。 谁知道一个简单的 await 表达式会引起这么多的开销呢?!

我们来看看这些开销来自哪里。 第一行负责封装 promise。 第二行立即用 await 得到的值 v 解开了封装。这两行带来了一个额外的 promise,同时也带来了三个 microticks 中的两个。 在 v 已经是一个 promise 的情况下(这是常见的情况,因为通常 await 的都是 promise),这中操作十分昂贵。 在不太常见的情况下,开发者 await 例如 42 的值,引擎仍然需要将它包装成一个 promise。

事实证明,规范中已经有 promiseResolve 操作,只在必要时执行封装:

此操作一样会返回 promises,并且只在必要时将其他值包装到 promises 中。 通过这种方式,你可以少用一个额外的 promise,以及 microtask 队列上的两个 tick,因为一般来说传递给 await 的值会是 promise。 这种新行为目前可以使用 V8 的 --harmony-await-optimization 标志实现(从 V8 v7.1 开始)。 我们也向 ECMAScript 规范提交了此变更,该补丁会在我们确认它与 Web 兼容之后马上打上。


以下展示了新改进的 await 是如何一步步工作的:


让我们再次假设我们 await 一个返回 42 的 promise。感谢神奇的 promiseResolve,现在 promise 只引用同一个 promise v,所以这一步中没有任何关系。 之后引擎继续像以前一样,创建 throwaway promise,生成 PromiseReactionJob 在 microtask 队列的下一个 tick 上恢复异步函数,暂停函数的执行,然后返回给调用者。


最终当所有 JavaScript 执行完成时,引擎开始运行 microtask,所以 PromiseReactionJob 被执行。 这个工作将 promise 的结果传播给 throwaway,并恢复 async 函数的执行,从 await 中产生 42。


Summary of the reduction in await overhead


如果传递给 await 的值已经是一个 promise,那么这种优化避免了创建 promise 封装器的需要,这时,我们把最少三个的 microticks 减少到了一个。 这种行为类似于 Node.js 8 的做法,不过现在它不再是 bug 了 - 它是一个正在标准化的优化!


尽管引擎完全内置,但它必须在内部创造 throwaway promise 仍然是错误的。 事实证明,throwaway promise 只是为了满足规范中内部 performPromiseThen 操作的 API 约束。



最近的 ECMAScript 规范解决了这个问题。 引擎不再需要创建 await 的 throwaway promise - 大部分情况下[2]

Comparison of await code before and after the optimizations


将 Node.js 10 中的 await 与可能在 Node.js 12 中得到优化的 await 对比,对性能的影响大致如下:

async/await 优于手写的 promise 代码。 这里的关键点是我们通过修补规范[3]显着减少了异步函数的开销 - 不仅在 V8 中,而且在所有 JavaScript 引擎中。



开发体验提升


除了性能之外,JavaScript 开发人员还关心诊断和修复问题的能力,这在处理异步代码时并没那么简单。 Chrome DevTool 支持异步堆栈跟踪,该堆栈跟踪不仅包括当前同步的部分,还包括异步部分:

这在本地开发过程中非常有用。 但是,一旦部署了应用,这种方法就无法起作用了。 在事后调试期间,你只能在日志文件中看到 Error#stack 输出,而看不到任何有关异步部分的信息。


我们最近一直在研究零成本的异步堆栈跟踪,它使用异步函数调用丰富了 Error#stack 属性。 “零成本”听起来很振奋人心是吧? 当 Chrome DevTools 功能带来重大开销时,它如何才能实现零成本? 举个例子🌰,其中 foo 异步调用了 bar ,而 bar 在 await promise 后抛出了异常:

在 Node.js 8 或 Node.js 10 中运行此代码会输出:

请注意,虽然对 foo() 的调用会导致错误,但 foo 并不是堆栈跟踪的一部分。 这让 JavaScript 开发者执行事后调试变得棘手,无论你的代码是部署在 Web 应用程序中还是云容器内部。

有趣的是,当 bar 完成时,引擎知道它该继续的位置:就在函数 foo 中的 await 之后。 巧的是,这也是函数 foo 被暂停的地方。 引擎可以使用此信息来重建异步堆栈跟踪的部分,即 await 点。 有了这个变更,输出变为:

在堆栈跟踪中,最顶层的函数首先出现,然后是同步堆栈跟踪的其余部分,然后是函数 foo 中对 bar 的异步调用。此变更在新的 --async-stack-traces 标志后面的 V8 中实现。


但是,如果将其与上面 Chrome DevTools 中的异步堆栈跟踪进行比较,你会注意到堆栈跟踪的异步部分中缺少 foo 的实际调用点。如前所述,这种方法利用了以下原理:await 恢复和暂停位置是相同的 - 但对于常规的 Promise#then() 或 Promise#catch()调用,情况并非如此。更多背景信息请参阅 Mathias Bynens 关于为什么 await 能打败 Promise#then() 的解释。



结论

感谢以下两个重要的优化,使我们的异步函数更快了:

  • 删除两个额外的 microticks;

  • 取消 throwaway promise;


最重要的是,我们通过零成本的异步堆栈跟踪改进了开发体验,这些跟踪在异步函数的 await 和 Promise.all() 中运行。

我们还为 JavaScript 开发人员提供了一些很好的性能建议:

  • 多用异步函数和 await 来替代手写的 promise;

  • 坚持使用 JavaScript 引擎提供的原生 promise 实现,避免 await 使用两个 microticks;


英文原文:https://v8.dev/blog/fast-async


好文推荐:

React 16.x 路线图公布,包括服务器渲染的 Suspense 组件及Hooks等


“UC国际技术”致力于与你共享高质量的技术文章

欢迎关注我们的公众号、将文章分享给你的好友


关注下面的标签,发现更多相似文章
评论