更快的异步函数和 Promise

发布时间 · 标签: ECMAScript benchmarks presentations

JavaScript 中的异步处理历来因其不是特别快而闻名(:p)。更糟糕的是,调试实时 JavaScript 应用程序 - 例如 Node.js 服务器 - 并非易事,尤其是涉及到异步编程时更甚。幸运的是,现在有了一个重大的改变。本文探讨了我们如何在 V8(甚至其它 JavaScript 引擎中)中优化异步函数和 promise,并描述了我们如何改进异步代码的调试体验。

:如果您更喜欢观看演示文稿,请欣赏下面的视频!如果没有,请跳过视频并继续阅读。

一种新的异步编程方法 #

从回调到 Promise 到异步函数 #

在 promise 被加入到 JavaScript 语言之前,异步代码一般使用基于回调的 API,尤其是在 Node.js 中。这是一个例子:

function handler(done) {
validateParams((error) => {
if (error) return done(error);
dbQuery((error, dbResults) => {
if (error) return done(error);
serviceCall(dbResults, (error, serviceResults) => {
console.log(result);
done(error, serviceResults);
});
});
});
}

当嵌套回调变的越来越深以后,我们称这种模式为“回调地狱”,因为它使代码不易读取且难以维护。

幸运的是,现在 promise 成了 JavaScript 语言的一部分,相同的代码可以以更优雅和可维护的方式编写:

function handler() {
return validateParams()
.then(dbQuery)
.then(serviceCall)
.then(result => {
console.log(result);
return result;
});
}

最近,JavaScript 开始支持了 异步函数。现在可以用与同步代码非常相似的方式编写上述异步代码:

async function handler() {
await validateParams();
const dbResults = await dbQuery();
const results = await serviceCall(dbResults);
console.log(results);
return results;
}

使用异步函数,代码变得更加简洁,并且数据流更容易控制,尽管执行仍然是异步的。(请注意,JavaScript 执行仍然发生在一个线程中,这意味着异步函数本身不会创建真实的物理线程。)

从事件监听回调到异步迭代器 #

另一个在 Node.js 中特别常见的异步范例是 ReadableStream。这是一个例子:

const http = require('http');

http.createServer((req, res) => {
let body = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
res.write(body);
res.end();
});
}).listen(1337);

这段代码有点难以理解:传入的数据只能在回调函数中以 chunks 的方式处理,并且流的结束信号也在回调函数内发生。如果你没有意识到函数其实已经立即终止了,并且必须在回调函数中进行实际处理,那么很容易在这里引入错误。

幸运的是,一个很酷的新的 ES2018 特性异步迭代器 async iteration 可以简化此代码:

const http = require('http');

http.createServer(async (req, res) => {
try {
let body = '';
req.setEncoding('utf8');
for await (const chunk of req) {
body += chunk;
}
res.write(body);
res.end();
} catch {
res.statusCode = 500;
res.end();
}
}).listen(1337);

现在我们不需要将实际处理的逻辑分别放在两个不同的回调函数中 - '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)开始已经完全支持异步迭代器和生成器!

异步性能改进 #

我们已经成功地在 V8 v5.5(Chrome 55 和 Node.js 7)和 V8 v6.8(Chrome 68 和 Node.js 10)之间显着提高了异步代码的性能。我们已经使引擎达到了一定的性能水平,以便开发者可以安全地使用这些新的编程范例,而无需担心速度。

上图是 doxbee benchmark,它评估了 Promise 的性能。请注意,图表中的执行时间越低意味着性能越好。

parallel benchmark 的结果则更加强调了 Promise.all() 的性能,更令人兴奋:

我们设法将 Promise.all 的性能提高了 8 倍。

但是,上述基准测试是跑分测试(synthetic micro-benchmarks)。V8 团队对真实世界的实际用户代码性能更感兴趣。

上面的图表展示了一些流行的 HTTP 中间件及框架的性能,这些框架大量使用了 Promise 和 async 函数。请注意,此图表显示了每秒的请求数(requests/second),因此与之前的图表不同,这个图表中,柱状图越高表示越好。这些框架的性能在 Node.js 7(V8 v5.5)和 Node.js 10(V8 v6.8)之间得到了显着提升。

这些性能改进主要得益于以下三项关键成果:

  • TurboFan,新的优化编译器 🎉
  • Orinoco,新的垃圾收集器 🚛
  • Node.js 8 的 bug,await 跳过 microticks 🐛

当我们在 Node.js 8推出TurboFan 时,全面提升了性能。

我们一直在研究一种新的垃圾收集器,我们称之为 Orinoco,它可以将垃圾收集工作从主线程中移除,从而显着改善了垃圾收集的请求处理。

最后,虽然放在后面但是并非不重要,Node.js 8 中有一个 bug 导致 await 在某些情况下跳过 microticks,从而产生更好的性能。这个 bug 的原因是我们违反了 es 的规范,但它后来给了我们关于优化的灵感。让我们从有缺陷的行为开始:

const p = Promise.resolve();

(async () => {
await p; console.log('after:await');
})();

p.then(() => console.log('tick:a'))
.then(() => console.log('tick:b'));

上面的程序创建了一个状态为 fulfilled 的 Promise:p,然后 await 取得它的结果,与此同时也将后面的 2 个 then 函数处理程序链接到它上面。您希望以哪种顺序执行 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 和异步函数的神奇之处前,让我们从一些更加基础的情况开始。

Tasks vs. microtasks #

在高层次上,JavaScript 中有 taskmicrotask。task 用于处理 I/O 和计时器等事件,每次执行一个。microtask 为 async/await 和 Promise 实现延迟执行,并在每个 task 结束时执行。在每一个事件循环之前,microtask 队列总是被清空(执行)。

微任务和任务之间的区别

更多详细信息,请查看 Jake Archibald 对浏览器中的 tasks、microtasks、queues 与 schedules中文翻译)的解释。Node.js 中的任务模型与此非常相似。

异步函数 #

根据 MDN,异步函数是一个使用隐式 Promise 异步操作以返回其结果的函数。异步函数旨在使异步代码看起来像同步代码,为开发者隐藏异步处理的一些复杂性。

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

async function computeAnswer() {
return 42;
}

当这个异步函数被调用时,它返回一个 Promise,你可以像任何其他的 Promise 那样获得它的值。

const p = computeAnswer();
// → Promise

p.then(console.log);
// prints 42 on the next turn

只有 p 在下次运行 microtask 时才能获得此 Promise 的值。换句话说,上面的程序在语义上等同于对值调用 Promise.resolve

function computeAnswer() {
return Promise.resolve(42);
}

异步函数的真正威力来自 await 表达式,它会暂停函数的执行直到 Promise 状态变为 resolved,并在执行后恢复。await 的值是 Promise 被 fulfilled 的值。这意味着什么?下面是一个示例:

async function fetchStatus(url) {
const response = await fetch(url);
return response.status;
}

await 暂停了函数 fetchStatus 的执行,稍后在 fetch 返回的 Promise 状态变为 fulfilled 时恢复了执行。这或多或少等同于将把处理过程写在 fetch 返回 Promise 的 then 链。

function fetchStatus(url) {
return fetch(url).then(response => response.status);
}

该处理程序在异步函数种包含了 await 代码。

通常你会传递 Promiseawait,但你实际上可以等待(await)任意的 JavaScript 值。如果 await 后面的表达式的值不是 Promise,则将其转换为 Promise。这意味着你可以这样写 await 42

async function foo() {
const v = await 42;
return v;
}

const p = foo();
// → Promise

p.then(console.log);
// prints `42` eventually

更有趣的是,await 可以使用任何 “thenable”,即任何带有 then 方法的对象,即使它不是真正的 Promise。因此,您可以实现有趣的事情,例如测量实际 sleep 时间的异步 sleep 功能:

class Sleep {
constructor(timeout) {
this.timeout = timeout;
}
then(resolve, reject) {
const startTime = Date.now();
setTimeout(() => resolve(Date.now() - startTime),
this.timeout);
}
}

(async () => {
const actualTime = await new Sleep(1000);
console.log(actualTime);
})();

接下来,让我们看看 V8 引擎底层是如何实现 await 规范的。这是一个简单的异步函数 foo

async function foo(v) {
const w = await v;
return w;
}

当函数调用时,它会将参数 v 包装为 Promise 并暂停执行异步函数,直到该 Promise 的状态变为 resolved。一旦发生这种情况,函数的执行将恢复并且这个 fulfilled 的 Promise 的值被赋值给 w。然后从异步函数中返回此值。

引擎底层的 await #

首先,V8 将此函数标记为可恢复(resumable),这意味着可以暂停执行并稍后恢复执行(在 await 处)。然后它创建所谓的 implicit_promise(隐式 Promise),这是在调用异步函数时返回的 Promise,并最终解析(resolve)为异步函数生成的值。

简单的异步函数与引擎转换之后的代码之间的比较

然后是有趣的一点:实际的 await。首先,传递给 await 的值被包裹在一个 Promise 中。然后,处理程序附加到这个包装的 Promise,以便在 Promise 变为 fulfilled 后恢复该函数,并且暂停执行异步函数,并将 implicit_promise 返回给调用者。一旦 promise 变为 fulfilled,恢复异步函数的执行,并将 promise 的值赋值给 w,而且这个 w 也是 implicit_promise 被 resolved 后的值。

简而言之,await v 的最初的执行步骤是:

  1. v 转换为 Promise- v 代表传递给 await 的值。
  2. 给 Promise 附加处理程序以便稍后恢复异步函数。
  3. 挂起异步函数并返回 implicit_promise 给调用者。

让我们一步一步地完成各个操作。假设传递给 await 的内容已经是一个 Promise,而它的 fulfilled 的值是 42。随后 V8 引擎又创建一个新的 promise 并对 await 后面的 Promise 执行 resolve 操作从而取出值。这确实推迟了下一轮的 Promise 处理链,这些被定义在规范中的 PromiseResolveThenableJob

然后引擎创造了另一个所谓的 throwaway Promise。它被称为 throwaway,因为它的 then 链没有任何处理程序 - 它完全在引擎内部。此 throwaway 然后被链接到 promise,使用适当的处理程序来恢复异步函数。这个 performPromiseThen 操作基本上就是 Promise.prototype.then() 的幕后操作。最后,暂停执行异步函数,并且控制权返回给调用者。

调用者继续执行,最终调用栈变空。然后 JavaScript 引擎开始运行 microtask:它运行之前安排的计划任务 PromiseResolveThenableJob,该计划任务又安排了新的 PromiseReactionJob,作为 await 之后的 Promise 的处理链。然后,引擎返回并处理 microtask 队列,因为在继续主事件循环之前必须清空 microtask 队列。

接下来是 PromiseReactionJob,它将 promise 设置为状态 fulfilled,其值是我们正在 await 的 Promise 值 - 在这个例子中是 42 - 并且将计划任务链到 throwaway Promise。然后引擎再次返回 microtask 循环,其中包含要处理的最终 microtask。

现在这第二个 PromiseReactionJob 将 resove 的值传播到 throwaway promise,并恢复异步函数的执行,await 的返回值为 42

await 的开销

总结一下,每个 await 引擎必须创建两个额外的 Promise(即使右侧已经是一个 Promise)并且它需要至少三个 microtask 队列 ticks。谁会意识到仅仅是一个 await 表达就导致了如此之多的开销?!

我们来看看这些开销来自哪里。第一行创建了 Promise 包装器。第二行立即使用 await 解析 Promise 包装器 v 的值。这两行导致了另外一个额外的 Promise 和三个 microtick 中的两个。如果 v 已经是一个 Promise(这是常见的情况,因为应用程序通常会在 Promise 上调用 await),这是非常昂贵的。在开发者不常使用的情况下,例如 await 42,引擎仍然需要为其创建 Promise 包装器。

事实证明,规范中已经有一个 promiseResolve 操作,此操作只在需要时执行包装器:

此操作返回没有修改过的 promise,并且只在必要时将其值包装到 promise 中。当传递给 await 的值已经是一个 Promise 时,这可以节省其中一个额外的 promise,加上 microtick 队列上的两个 tick。从 V8 v7.1 开始,该行为可以通过 V8 的命令行参数 --harmony-await-optimization 开启。我们也提交了对 proposed this change to the ECMAScript specification 的变更,此变更已经被合并。

以下是在引擎底层对 await 的改进,其按步执行的工作方式如下:

让我们再次假设我们 await 后面的 Promise 返回了 42。感谢 promiseResolve 带来的魔法,现在 promise 指向了同一个 Promise v,所以这个步骤什么也不需要做。然后引擎继续像以前一样,创建 throwaway Promise,安排 PromiseReactionJob 在 microtask 队列的下一个 tick 上恢复异步函数,暂停执行该函数,然后返回给调用者。

最终当所有 JavaScript 执行完成时,引擎开始运行 microtask,因此它执行 PromiseReactionJob。这个过程将 promise 传播到 throwaway,并恢复异步函数的执行,为 await 得到 42

节省了执行 await 的开销

如果传递给 await 的值已经是一个 Promise,那么这种优化避免了再次创建 Promise 包装器,在这种情况下,我们从最少三个 microtick 到只有一个 microtick。这种行为类似于 Node.js 8 所做的,但是现在它不再是一个 bug - 它现在是一个正在标准化的优化!

虽然 throwaway 只是在 V8 引擎内部使用,但引擎必须创造这种 Promise。事实证明,throwaway Promise 只是为了满足 performPromiseThen 规范中内部操作的 API 约束。

最近在 ECMAScript 规范的编辑性更改中解决了这个问题。引擎不再需要为 await 创造 throwaway Promise - 在绝大部分时间[2]

await 优化之前和之后的比较

同 Node.js 10 的 await 对比,在 Node.js 12 中做了更进一步的优化,下图显示了此更改对性能的影响:

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

更新: 在 V8 v7.2 和 Chrome 72 中,--harmony-await-optimization 已经默认开启。此补丁也已经合并到了 ECMAScript 规范中。

改善开发者体验 #

除了性能之外,JavaScript 开发者还关心诊断和修复 bug 的能力,这在处理异步代码时通常会更加困难。Chrome DevTools 支持异步堆栈跟踪,即堆栈跟踪不仅包括堆栈的当前同步部分,还包括异步部分:

这是本地开发过程中非常有用的功能。但是,一旦部署了应用程序,这种方法并没有真正帮助您。在线上调试期间,您只会在日志文件中看到 Error#stack 输出,并且不会告诉您有关异步部分的任何信息。

我们最近一直在研究零成本的异步堆栈跟踪,它为异步函数调用提供了更丰富的 Error#stack 属性。“零成本”听起来令人兴奋,不是吗?当 Chrome DevTools 特性带来重大开销时,如何才能实现零成本?考虑这个 foo 异步调用 bar 的例子,而且 barawait 的 Promise 之后抛出异常:

async function foo() {
await bar();
return 42;
}

async function bar() {
await Promise.resolve();
throw new Error('BEEP BEEP');
}

foo().catch(error => console.log(error.stack));

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

$ node index.js
Error: BEEP BEEP
at bar (index.js:8:9)
at process._tickCallback (internal/process/next_tick.js:68:7)
at Function.Module.runMain (internal/modules/cjs/loader.js:745:11)
at startup (internal/bootstrap/node.js:266:19)
at bootstrapNodeJSCore (internal/bootstrap/node.js:595:3)

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

这里有趣的是,引擎知道 bar 调用完成时它继续执行的位置:在 foo 函数的 await 之后。巧合的是,这也是函数 foo 暂停的地方。引擎可以使用此信息来重建异步堆栈跟踪的部分,即 await 现场。通过此更改,输出变为:

$ node --async-stack-traces index.js
Error: BEEP BEEP
at bar (index.js:8:9)
at process._tickCallback (internal/process/next_tick.js:68:7)
at Function.Module.runMain (internal/modules/cjs/loader.js:745:11)
at startup (internal/bootstrap/node.js:266:19)
at bootstrapNodeJSCore (internal/bootstrap/node.js:595:3)
at async foo (index.js:2:3)

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

更新: 在 V8 v7.3 种,--async-stack-traces 默认开启。

但是,如果将此与上面 Chrome DevTools 中的异步堆栈跟踪进行比较,您会注意到 foo 堆栈跟踪的异步部分中缺少实际的调用现场。如前所述,这种方法利用了一个事实,await 即恢复和暂停位置是相同的 - 但对于常规 Promise#then()Promise#catch() 调用,情况并非如此。有关更多背景信息,请参阅 Mathias Bynens 对why await beats Promise#then() 的解释。

结论 #

由于两个重要的优化,我们使异步函数更快:

  • 删除两个额外的 microtick,和
  • 去除了 throwaway promise。

最重要的是,我们通过零成本异步堆栈跟踪改进了开发体验,这些可以使用在异步函数的 await 表达式和异步函数中使用 Promise.all()

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

  • 使用 async 函数和 await 替代手写的 Promise 代码,以及
  • 坚持 JavaScript 引擎提供的原生 Promise 实现,以避免在 await 中使用额外的两个 microtick。

  1. 感谢 Matteo Collina 为此提交的 issue. ↩︎

  2. 如果在 Node.js 中使用 async_hooks,V8 仍然需要创建 throwaway,因为 beforeafter 钩子需要在 throwaway 的 promise 上下文中运行。 ↩︎

作者:Maya Lekova (@MayaLekova), always-awaiting anticipator, and Benedikt Meurer (@bmeurer), professional performance promiser.