原文作者: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 实现了正确的行为,即先执行链式处理程序,然后继续执行异步函数。
这种“正确的行为”可以说并不是很明显,也挺令 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国际技术”致力于与你共享高质量的技术文章
欢迎关注我们的公众号、将文章分享给你的好友