原文做者: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 实现了正确的行为,即先执行链式处理程序,而后继续执行异步函数。
这种“正确的行为”能够说并非很明显,也挺令 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 队列。
await
的开销
总结以上所学,对于每一个 await,引擎都必须建立两个额外的 promise(即便右边的表达式已是 promise)而且它须要至少三个 microtask 队列执行。 谁知道一个简单的 await 表达式会引发这么多的开销呢?!
事实证实,规范中已经有 promiseResolve 操做,只在必要时执行封装:
此操做同样会返回 promises,而且只在必要时将其余值包装到 promises 中。 经过这种方式,你能够少用一个额外的 promise,以及 microtask 队列上的两个 tick,由于通常来讲传递给 await 的值会是 promise。 这种新行为目前可使用 V8 的 --harmony-await-optimization 标志实现(从 V8 v7.1 开始)。 咱们也向 ECMAScript 规范提交了此变动,该补丁会在咱们确认它与 Web 兼容以后立刻打上。
如下展现了新改进的 await 是如何一步步工做的:
最终当全部 JavaScript 执行完成时,引擎开始运行 microtask,因此 PromiseReactionJob 被执行。 这个工做将 promise 的结果传播给 throwaway,并恢复 async 函数的执行,从 await 中产生 42。
await
overhead
若是传递给 await 的值已是一个 promise,那么这种优化避免了建立 promise 封装器的须要,这时,咱们把最少三个的 microticks 减小到了一个。 这种行为相似于 Node.js 8 的作法,不过如今它再也不是 bug 了 - 它是一个正在标准化的优化!
尽管引擎彻底内置,但它必须在内部创造 throwaway promise 仍然是错误的。 事实证实,throwaway promise 只是为了知足规范中内部 performPromiseThen 操做的 API 约束。
最近的 ECMAScript 规范解决了这个问题。 引擎再也不须要建立 await 的 throwaway promise - 大部分状况下[2]。
await
code before and after the optimizations
将 Node.js 10 中的 await 与可能在 Node.js 12 中获得优化的 await 对比,对性能的影响大体以下:
除了性能以外,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国际技术”致力于与你共享高质量的技术文章
欢迎关注咱们的公众号、将文章分享给你的好友