V8中更快的异步函数和promise

本文详细的讲解了如何在V8中优化JavaScript异步编程,从最开始的回调到promise再到异步函数,清晰的分析了V8中是如何实现更快的异步函数和promise。

译文来自公众号“前端之巅”,译者|无明

JavaScript 的异步处理因为不是特别快而显得有点臭名昭著。更糟糕的是,调试实时 JavaScript 应用程序——特别是 Node.js 服务器是一件非常麻烦的事,特别是在涉及异步编程时。所幸的是,这些情况正在发生改变。本文将探讨如何在 V8 中优化异步函数和 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;
}

有了异步函数,代码变得更加简洁,并且控制流和数据流变得更清晰,尽管执行仍然是异步的。

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

另一个在 Node.js 中非常常见的异步范式是 ReadableStreams。例如:

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);

这段代码有点难以理解:传入的数据被分成块进行处理,并且数据块只能在回调中可见,而且流的结束信号也是在回调内发出。这种方式很容易引入 bug。

幸运的是,ES2018 引入了一项新特性,叫作异步迭代,可以简化这段代码:

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);

我们并没有将处理实际请求的逻辑放在两个不同的回调中——“date”和“end”回调——而是将所有内容放在单个异步函数中,并使用 for await…of 循环进行异步迭代。我们还添加了一个 try-catch 块来避免 unhandledRejection 问题。

你现在已经可以在生产中使用这些新功能了!从 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 中间件框架的性能,这些框架大量使用了 promise 和异步函数。请注意,此图表显示的是请求数 / 秒,因此与之前的图表不同,这次是越高越好。这些框架的性能在 Node.js 7(V8 v5.5)和 Node.js 10(V8 v6.8)之间的版本上得到了显著改善。

这些性能改进主要归功于这三项关键成果:

  • TurboFan(https://v8.dev/docs/turbofan),新的优化编译器;
  • Orinoco(https://v8.dev/blog/orinoco),新的垃圾回收器;
  • 一个 Node.js 8 错误导致 await 跳过 microtick。

我们在 Node.js 8 中推出了 TurboFan,性能得到全面提升。

我们一直在开发一种叫作 Orinoco 的新垃圾回收器,它可以将垃圾回收工作从主线程中剥离出来,从而显著提升了请求处理的性能。

Node.js 8 中有一个错误会导致 await 在某些情况下跳过 microtick,从而得到更好的性能。这个错误最开始是违反规范的,但后来却变成了我们进行优化的灵感来源。

const p = Promise.resolve();

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

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

上面的代码创建了一个 promise p,并等待它的结果,同时还链接了两个处理函数。你认为 console.log 会以哪种顺序执行调用?

因为 p 已经完成,你可能希望它首先打印“after:await”然后打印“tick”。实际上,在 Node.js 8 中你会得到这样的行为:

虽然这种行为看起来很直观,但根据规范,它是不对的。Node.js 10 实现了正确的行为,应该首先执行被链接的处理程序,然后再调用异步函数。

这种“正确的行为”可以说并不是很明显,JavaScript 开发人员可能会感到惊讶,所以这里需要解释一下。在我们深入了解 promise 和异步函数之前,让我们从一些基础知识开始。

任务与微任务

在高层面看,JavaScript 中存在任务和微任务。任务负责处理 I/O 和计时器等事件,每次执行一个。微任务实现了 async/await 和 promise 的延迟执行,并且是在每个任务结束时执行。在执行返回到事件循环之前,微任务队列会被清空。

异步函数

根据 MDN 文档所述,异步函数是一种使用隐式 promise 执行异步操作并返回结果的函数。异步函数旨在使异步代码看起来像同步代码,为开发人员隐藏异步处理的一些复杂性。

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

async function computeAnswer() {
  return 42;
}

当被调用时,它返回一个 promise,你可以像其他 promise 一样获取它的值。

const p = computeAnswer();
// → Promise

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

你只有在下次运行微任务时才能获得这个 promise 的值。换句话说,上面的代码在语义上等同于使用 Promise.resolve:

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

异步函数的真正威力来自 await 表达式,它会暂停函数执行,直到 promise 完成后恢复。await 的值就是 promise 的结果。这是一个示例:

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

fetchStatus 的执行在 await 上暂停,并在 fetch promise 完成时恢复。这或多或少等同于将处理程序链接到从 fetch 返回的 promise。

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

通常你会将一个 Promise 传给 await,但实际上你可以 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);
})();

可以参看规范(https://tc39.github.io/ecma262/#await),看看 V8 对 await 做了什么。这是一个简单的异步函数 foo:

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

当函数被调用时,它将参数 v 包装到一个 promise 中,并暂停执行异步函数,直到这个 promise 完成。然后函数的执行将恢复,将 promise 的值赋给 w,然后从异步函数返回这个值。

深入了解 await

首先,V8 将这个函数标记为可恢复,这意味着可以暂停执行并稍后恢复。然后它创建所谓的 implicit_promise,它是在调用异步函数时返回的 promise。

然后是最有趣的部分:实际的 await。首先,传给 await 的值被包装到一个 promise 中。然后,处理程序被附加到这个包装的 promise,以便在 promise 完成后恢复函数,然后异步函数的执行被挂起,将 implicit_promise 返回给调用者。在 promise 完成之后,就会使用 promise 的值 w 恢复异步函数的执行,implicit_promise 的值就是 w。

简单地说,await v 的初始步骤包括:

  1. 将 v——传给 await 的值——包装成 promise;
  2. 附加处理程序以便稍后恢复异步函数;
  3. 挂起异步函数,并将 implicit_promise 返回给调用者。

让我们逐步分析这些操作。假设正在 await 的东西已经是一个 promise,值为 42。然后引擎创建了一个新的 promise,并用正在 await 的东西来完成它。

让我们逐步分析这些操作。假设正在 await 的东西已经是一个 promise,值为 42。然后引擎创建了一个新的 promise,并用正在 await 的东西来完成它。这推迟了下一轮 promise 的链接,并通过规范中称为 PromiseResolveThenableJob 的东西来描述。

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

执行在调用者中继续,直到调用栈变空。然后 JavaScript 引擎开始运行微任务:它运行先前调度的 PromiseResolveThenableJob,它会调度新的 PromiseReactionJob,并将 promise 链接到传给 await 的值。然后,引擎返回去处理微任务队列,因为在继续主事件循环之前必须清空微任务队列。

接下来是 PromiseReactionJob,它完成了 promise,值为 42,并调度 throwaway promise。然后,引擎再次返回微任务循环,其中包含了最终需要处理的微任务。

现在,第二个 PromiseReactionJob 完成了 throwaway promise,并恢复异步函数执行,从 await 返回 42。

对于每个 await,引擎都必须创建两个额外的 promise,并需要至少三个微任务。谁会想到一个 await 表达式会导致这么多的开销?!

我们来看看这些开销来自哪里。第一行负责创建包装器 promise,第二行立即用 await 值 v 完成了包装器 promise。这两行负责一个额外的 promise 加上三个微任务中的两个。如果 v 是一个 promise(这是很常见的情况,因为应用程序通常会 await promise),开销就非常大。比如,开发人员在 42 上 await,那么引擎仍然需要将它包装成一个 promise。

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

这个操作照原样返回 promise,并且只在必要时将其他值包装成 promise。当传给 await 的值已经是 promise 时,你可以通过这种方式保存其中一个额外的 promise,以及微任务队列上的两个任务。这种新行为已经通过 V8 中的 --harmony-await-optimization 标志实现了(从 V8 v7.1 开始)。我们也向 ECMAScript 规范提交了这个变更提案,在我们确定它与 web 兼容后,就应该会合并补丁。

以下是新的改进后的 await 的执行步骤:

让我们再次假设我们 await 一个结果为 42 的 promise。因为有了 promiseResolve,现在 promise 引用的是相同的 promise v,所以在这一步没什么可做的。之后,引擎将继续像以前一样,创建 throwaway promise,调度 PromiseReactionJob 以便在后面恢复异步函数、暂停函数的执行,以及返回给调用者。

最后,当所有 JavaScript 执行完成时,引擎开始运行微任务,这个时候会执行 PromiseReactionJob。这个作业将 promise 的结果传播给 throwaway,并恢复异步函数的执行,await 将生成 42。

如果传给 await 的值已经是一个 promise,那么就可以避免创建包装器 promise,在这种情况下,我们只需要一个 microtick。这种行为类似于 Node.js 8 所做的,只是现在它不再是一个 bug,而是一个正在被标准化的优化方案!

引擎仍然会创建 throwaway promise,尽管是在内部,但感觉不太对。事实证明,throwaway promise 只是为了满足规范对内部 performPromiseThen 操作的 API 约束。

这个问题在最近的 ECMAScript 规范编辑性变更中得到了解决。在多数情况下,引擎不再需要为 await 创建 throwaway promise。

将 Node.js 10 中的 await 与可能在 Node.js 12 中发布的优化版 await 进行比较,显示了这个变更对性能的影响:

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

改进的开发者体验

除了性能之外,JavaScript 开发者还关心诊断和修复 bug 方面的问题,这些在处理异步代码时并不总是那么容易。Chrome DevTools 支持异步堆栈跟踪,堆栈跟踪不仅包括堆栈的当前同步部分,还包括异步部分:

在进行本地开发时,这是非常有用的一项功能,但对于已经部署的应用程序,这种方法并不会真正帮助到你。在进行事后调试时,你只会在日志文件中看到 Error#stack 输出,它们并不会告诉你有关异步部分的任何信息。

我们最近一直在研究零成本的异步堆栈跟踪,通过异步函数调用丰富了 Error#stack 属性。”零成本”听起来很令人兴奋,不是吗?Chrome DevTools 会带来重大的开销,那它又是如何做到零成本的?可以看看这个示例,其中 foo 异步调用 bar,bar 在 await 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 被暂停的位置。引擎可以使用这些信息来重建部分异步堆栈跟踪,于是输出变为:

$ 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)

在堆栈跟踪中,最顶层的函数首先出现,然后是同步堆栈跟踪的其余部分,再然后是函数 foo 中对 bar 的异步调用。在 V8 中,这个变更是通过 --async-stack-traces 标志实现的。

但是,如果将它与 Chrome DevTools 中的异步堆栈跟踪进行比较,你会注意到,堆栈跟踪的异步部分中缺少 foo 的实际调用信息。如前所述,这种方法利用了以下事实:对于 await 来说,恢复和暂停位置是相同的——但对于常规的 Promise#then() 或 Promise#catch() 调用,情况并非如此。

 结 论

我们进行了两个重要的优化,让异步函数变得更快:

  • 移除两个额外的 microtick;
  • 移除 throwaway promise。

最重要的是,我们通过零成本的异步堆栈跟踪改进了开发者体验。

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

  • 使用异步函数和 await 代替手写的 promise 代码;
  • 坚持使用 JavaScript 引擎提供的原生 promise 实现,这样可以避免为 await 使用两个 microtick。

英文原文:https://v8.dev/blog/fast-async