V8 中更快的异步函数和 promises


原文做者:Maya Lekova and Benedikt Meurer前端

译者:UC 国际研发 Jothy算法


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


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

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

视频地址:安全

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



一种新的异步编程方法


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

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

咱们一般把这种使用深度嵌套回调的模式称为“回调地狱”,由于这种代码不易读取且难以维护。async

所幸,如今 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国际技术”致力于与你共享高质量的技术文章

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

相关文章
相关标签/搜索