更快的异步功能和promise
Last updated
Last updated
JavaScript中的异步处理传统上以速度不快而闻名。更糟的是,调试实时JavaScript应用程序(特别是Node.js服务器)不是一件容易的事情,尤其是在异步编程方面。幸运的是,时代正在改变。本文探讨了如何在V8中优化异步函数和promis(在某种程度上,在其他JavaScript引擎中也是如此),并描述了如何改进异步代码的调试体验。
注意: 如果你更喜欢观看演示文稿而不是阅读文章,那么请欣赏下面的视频!如果不是,请跳过视频并继续阅读。
在promises成为JavaScript语言的一部分之前,基于回调的API通常用于异步代码,尤其是在Node.js中。这是一个例子:
以这种方式使用深度嵌套回调的特定模式通常被称为“回调地狱” ,因为这会降低代码的可读性并且难以维护。
幸运的是,现在promises已经成为JavaScript语言的一部分,同样的代码可以用更优雅和更易于维护的方式编写:
最近,JavaScript获得了对异步功能的支持。现在可以用与同步代码非常相似的方式编写上述异步代码:
使用异步函数,代码变得更简洁,控制和数据流也更容易理解,尽管执行仍然是异步的。(注意,JavaScript执行仍然发生在一个线程中,这意味着异步函数最终不会自己创建物理线程。)
另一个在Node.js中特别常见的异步范例是ReadableStream。这是一个例子:
这段代码可能有点难理解: 传入的数据以块的形式处理,块只能在回调中访问,流结束信号也在回调中发生。当你没有意识到函数立即终止,而实际的处理必须在回调中进行时,很容易在这里引入bug。
幸运的是,一个名为异步迭代(async iteration)的很酷的ES2018新特性可以简化这段代码:
现在,我们可以将所有内容放入单个异步函数中,并使用新的for await…of
循环异步迭代块,而不是将实际请求处理的逻辑放入两个不同的回调('data'
和'end'
回调)中。我们还添加了一个try-catch
块来避免unhandledRejection
问题[1]。
[1] => 感谢Matteo Collina指出我们这个问题。
你现在可以在生产中使用这些新功能!从Node.js 8 (V8 v6.2 / Chrome 62)开始完全支持 异步函数,从Node.js 10 (V8 v6.8 / Chrome 68)开始完全支持 异步迭代器和生成器!
我们已经成功地在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和async
函数。请注意,此图表显示请求数/秒,因此与之前的图表 不同 ,越高的越好。这些框架的性能在Node.js 7(V8 v5.5)和Node.js 10(V8 v6.8)之间得到了显著改善。
这些性能的改善是下列三项主要成就的结果:
当我们在Node.js 8中发布TurboFan时,它在各个方面都带来了巨大的性能提升。
我们还开发了一个名为Orinoco的新垃圾收集器,它将垃圾收集工作移出主线程,从而显著地改进请求处理。
最后,但也挺重要的是,Node.js 8中有一个便利的bug,在某些情况下会导致await
跳过microticks,从而获得更好的性能。这个bug一开始是无意中违反了规范,但是后来给了我们一个优化的想法。让我们从解释错误行为开始:
上面的程序创建了一个完成(fulfilled)的promise p
,并await
其结果,但也将两个处理程序链接到它上面。你希望console.log
调用以哪种顺序执行?
由于p
已经完成(fulfilled),你可能希望它首先打印'after: await'
然后打'tick'
。实际上,这是你在Node.js 8中得到的行为:
虽然这种行为看起来很直观,但根据规范它并不正确。Node.js 10实现了正确的行为,即首先执行链式处理程序,然后才继续使用异步(async)函数。
这种“正确的行为”可以说并不是很明显,对JavaScript开发人员来说实际上是令人惊讶的,所以有必要解释一下。在深入到promise和异步(async)函数的神奇世界之前,让我们先从一些基础开始。
在高层次上,JavaScript中有(宏)任务和微任务之分。任务处理I/O和计时器等事件,并一次执行一个。微任务为async
/await
和promise实现延迟执行,并在每个任务结束时执行。在执行返回到事件循环之前,总是会清空微任务队列。(译:具体的可以查看这里)
有关更多详细信息,请查看Jake Archibald对浏览器中任务,微任务,队列和计划的解释。Node.js中的任务模型与之非常相似。
根据MDN,异步(async)函数是一个使用隐式promise来异步操作以返回其结果的函数。异步函数的目的是使异步代码看起来像同步代码,从而向开发人员隐藏异步处理的一些复杂性。
最简单的异步函数如下所示:
当被调用时,它返回一个promise,你可以像任何其他的promise那样获得它的值。
你只能在下一次运行微任务时获得这个promise p
的值。换句话说,上面的程序在语义上等同于使用带有值的Promise.resolve
:
异步函数的真正强大之处在于await
表达式,它导致函数执行暂停,直到一个promise被解决(resolved),并在实现之后恢复,继续执行下去。await
的值是完成(fulfilled) promise的值。下面是一个例子:
fetchStatus
的执行在await
期间暂停,然后在fetch
promise完成(fulfill)时恢复,继续执行下去。这或多或少相当于将处理程序链接到fetch
返回的promise上。
该处理程序包含async
函数中await
后面的代码。
通常,你会传递一个Promise
去await
,但实际上你可以await
任意JavaScript值。如果await
之后的表达式的值不是promise,则将其转换为promise。这意味着如果你愿意,可以await 42
:
更有趣的是,await
适用于任何'thenable'
,即任何带有then
方法的对象,即使它不是真正的promise。(译:关于thenable,可以查看这里)。所以你可以实现一些有趣的事情,比如测量实际sleeping时间的异步sleep:
按照规范,让我们看看V8为await
在引擎下做了什么。这是一个简单的异步函数foo
:
调用时,它将参数v
包装到promise中并暂停执行异步函数,直到解析该promise。一旦发生这种情况,函数的执行将继续,w
将获得已实现(fulfilled) promise 的值。然后从异步函数返回此值。
await
首先,V8将这个函数标记为 可恢复 的(resumable),这意味着可以暂停执行,然后恢复执行(await
的点)。然后它创建了所谓的implicit_promise
,当调用async函数时返回这个promise,并最终解析为async函数生成的值。
接下来是有趣的部分: 实际的await
。首先,传递给await
的值被包装到一个promise中。然后,处理程序被附加到这个包装的promise上,以在promise完成(fulfilled)后恢复函数,并暂停异步函数的执行,将implicit_promise
返回给调用者。一旦履行(fulfilled)了promise,就会使用promise中的值w
恢复异步函数的执行,并使用w
解析(resolve) implicit_promise
。
简而言之,await v
的初始步骤是:
将v(传递给await
的值)包装成一个promise。
附加用于稍后恢复异步函数的处理程序。
暂停异步函数并将implicit_promise
返回给调用者。
让我们一步一步地完成各个操作。假设正在await
的东西已经是一个promise,它已经完成(fulfilled)了,并且值是42
。然后,引擎创建一个新的promise
,并解决任何await
的东西。这将在下一个回合中延迟这些承诺的链接,通过规范所称的PromiseResolveThenableJob
来表示。
然后引擎创造了另一个所谓的throwaway
promise。它被称为 一次性(throwaway),因为没有任何东西被链接到它 - 它完全是引擎内部的。然后将这个throwaway
promise链接到promise上,使用适当的处理程序来恢复异步功能。这个performPromiseThen
操作基本上是Promise.prototype.then()
在幕后所做的。最后,暂停执行异步功能,并且控制权返回给调用者。
执行在调用者中继续,最终调用堆栈变空。然后JavaScript引擎开始运行微任务:它运行先前调度的PromiseResolveThenableJob
,它调度新的PromiseReactionJob
以将promise
链接到传递给await
的值。然后,引擎返回到处理微任务队列,因为在继续主事件循环之前必须清空微任务队列。
接下来是PromiseReactionJob
,它完成了我们正在await
的承诺值的promise
- 在这种情况下为42
- 并将反应计划在throwaway
promise上。然后引擎再次返回微任务循环,其中包含要处理的最终微任务。
现在,第二个PromiseReactionJob
将解析传播到throwaway
promise,并恢复异步函数的暂停执行,从await
返回值42
。
总结我们所学到的,对于每个await
,引擎必须创建 两个额外 的promise(即使右边已经是一个promise),并且它需要 至少三个 微任务队列。谁知道一个简单的await
会导致这么多开销 ?!
我们来看看这个开销来自何处。第一行负责创建包装 promise。第二行立即用包装的promise解析(resolve)await
的值v
。这两行代码由一个额外的promise加上三个microticks中的两个来负责。如果v
已经是一个promise(这是常见的情况,因为应用程序通常await
promise),这是非常昂贵的。在不太可能的情况下,开发人员await
例如42
这样的非promise,引擎仍然需要将它包装成一个promise。
事实证明,规范中已经有一个promiseResolve操作,只在需要时执行包装:
此操作仍然返回promises,并且只在必要时将其他值包装到promises中。这样就可以保存其中一个额外的promise,加上微任务队列上的两个tick,这是传递给await
的值已经是promise的常见情况。这个新行为目前是在V8中的--harmony-await-optimization
标志后面实现的(从V8 v7.1开始)。我们也提出了对ECMAScript规范的这种改变; 一旦我们确定它与Web兼容,就应该合并补丁。
以下是新的和改进后的await
如何在幕后一步一步地工作:
让我们再次假设我们在await
一个promise,并且完成的时候是42
。由于promiseResolve
的魔力,现在promise
只引用相同的promise v
,所以这一步不需要做什么。之后,引擎像之前一样继续运行,创建throwaway
promise,调度一个PromiseReactionJob
,以便在微任务队列上的下一个tick上恢复异步函数,暂停函数的执行,并返回给调用者。
然后最终当所有JavaScript执行完成时,引擎开始运行微任务,因此它执行PromiseReactionJob。这个任务将promise的解析传播到throwaway
,并恢复async函数的执行,从await
中产生42
。
如果传递给await
的值已经是一个promise,那么这种优化避免了创建包装promise的需要,在这种情况下,我们从最少 三个 microticks到 一个 microtick。这种行为类似于Node.js 8所做的,除了现在它不再是一个bug - 它现在是一个正在标准化的优化!
尽管引擎是完全内部的,但是引擎必须创建这种throwaway
promise的感觉仍然是错误的。事实证明,throwaway
promise只是为了满足规范中内部performPromiseThen
操作的API约束。
这一点最近在ECMAScript规范的编辑更改中得到了解决。引擎不再需要创造await
的throwaway
promise - 大部分时间[2]。
[2] => 如果在Node.js中使用async_hooks,V8仍然需要创建
throwaway
承诺,因为before
和after
钩子是在throwaway
promise的上下文中运行的。
将Node.js 10中的await
与Node.js 12中的优化await
进行比较,可以看出这种变化对性能的影响:
async
/await
现在比手写的promise代码执行得性能更好。 这里的关键点是我们通过修补规范[3]显着减少了异步函数的开销 - 不仅在V8中,而且在所有JavaScript引擎中。
[3] => 如前所述,补丁尚未合并到ECMAScript规范中。我们的计划是,一旦我们确定这个改变不会破坏web,我们就会这么做。↩︎
除了性能之外,JavaScript开发人员还关心诊断和修复问题的能力,这在处理异步代码时并不总是那么容易。Chrome DevTools支持 异步堆栈跟踪 ,即堆栈跟踪不仅包括堆栈的当前同步部分,还包括异步部分:
在本地开发期间,这是一个非常有用的特性。但是,一旦部署了应用程序,这种方法并不能真正帮助你。在事后调试期间,你只会在日志文件中看到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的异步调用。在V8中,这个更改是在新的--async-stack-trace
标志之后实现的。
但是,如果将其与上面Chrome DevTools中的异步堆栈跟踪进行比较,你会注意到堆栈跟踪的异步部分中缺少foo
的实际调用站点。如前所述,这种方法利用了这样一个事实,即等待恢复和暂停位置是相同的——但是对于常规的Promise#then()
或Promise#catch()
调用,情况就不是这样了。有关更多背景信息,请参见Mathias Bynens关于为什么await
胜过Promise#then()
的解释。
由于两个重要的优化,我们使异步函数更快:
删除两个额外的microticks
取消throwaway
promise。
除此之外,我们还通过零成本的async堆栈跟踪改进了开发人员的体验,这些跟踪在async函数和Promise.all()
中使用await
。
我们还为JavaScript开发人员提供了一些很好的性能建议:
偏向于async
函数和await
,而不是手写promise代码
坚持JavaScript引擎提供的原生promise实现,以从快捷方式中获益,即为await
避免两个microticks。
async得到了性能的提升,这是因为nodejs 8中的一个bug,由于这个bug使得v8开发人员得到了感悟,减少不必要的microtick。
await的值如果不是一个promise会被包装一个promise。这个是有问题的,因为大多数的时候,都是await
promise,所以有这么一个改进:
可以看到图中,如果是一个promise就直接返回了,否则就包装下。