更快的异步函数和期约翻译参考



长期以来,在 JavaScript 中的异步处理以不是非常快而周知。更糟糕的是,在 JavaScript 应用中实时调试并非简单的任务,对于 Node.js 服务器更是如此。如果涉及到异步编程,那就让人更痛苦了。不过还好,现在我们迎来了转机。这篇文章揭露了我们在 V8 中是如何优化异步函数和期约的(某种程度上说,算是在其他 JavaScript 引擎也进行了优化),同时对我们如何改进异步编码的调试体验做了描述。

Youtube 链接(需代理):Holding on to your Performance Promises - Maya Lekova and Benedikt Meurer

异步编程的新方法

从回调到期约,然后再是异步函数

在期约成为 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);
      });
    });
  });
}

像这种使用深度嵌套回调的具体模式通常被称为“回调地狱”。因为它削弱了代码的可读性,并使其难以维护。

不过好在,既然期约现在是 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 中极其常见的异步范式是 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);

这段代码可能有点令人费解:传入的数据以只能以块(chunk)的形式被处理,而这种块只能够在回调中访问;同时,流结尾(end-of-stream)信号传递也是在回调中发生的。如果你不清楚这种(外层的)函数会立刻终止,而实际处理必须在回调中发生时,极易引入程序错误(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);

我们现在可以不再将处理实际请求处理放入两个不同的回调——“data”“end” 回调之中,而是将所有逻辑放到单个的异步函数中,利用新的 for await...of 循环来异步地迭代块(chunk)了。同时,我们也添加了一个 try-catch 块以防出现 unhandleRejection 问题[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)之间,我们已经设法显著地改进了异步编码的性能。我们令其到达了开发者可以安全使用这些新的编程范式(programming paradigms)而无需担心速度的性能水平。

doxbee 基准测试结果

上图展示了 doxbee 基准测试 的结果,测量了重度使用期约(promise-heavy)代码的执行性能。注意该图表可视化了执行时间,这意味着越低越好。

而专门强调 Promise.all() 性能的并行基准测试结果,则更令人兴奋。

并行基准测试结果

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

但是,上述基准测试只是人造的微基准测试。V8 团队更感兴趣的是我们的优化是怎样影响现实世界中实际用户代码的性能的。

现实世界 http 中间件框架基准测试结果

上图可视化了一些流行 HTTP 中间件框架的性能,这些框架都重度使用期约和 async 函数。请注意,这张图展示的是每秒的请求数,所以与之前的图表不同,这里越高代表更好。这些框架的性能在 Node.js 7(V8 v5.5)和 Node.js 10(V8 v6.8)之间得到了显著的提升。

这些性能优化是三个关键成就的结果:

  • TurboFan,一个新的优化编译器(optimizing compiler)🎉
  • Orinoco,一个新的内存垃圾收集器(garbage collector)🚛
  • 一个导致 await 跳过微任务刻度(microtick)的 Node.js 8 程序错误 🐛

当我们在 Node.js 8 推出 TurboFan 时,它带来了巨大的性能提升。

我们也一直致力于开发一种名为 Orinoco 的新的内存垃圾收集器。它将垃圾回收工作从主线程中移出,因此也显著地优化了请求处理。

最后但同样重要的是,Node.js 8 中有一个非常便利的程序错误,其导致 await 在某些场景下跳过微任务刻度,导致了更好的性能。这个错误起初是无意的规范违反,但后来给予了我们对一次优化的构思。让我们从解释这一错误行为讲起:

const p = Promise.resolve();

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

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

上面的程序创建了一个已兑现(fulfilled)的期约 p,并 await 其结果,但仍在它上面链接(chain)了两个处理程序(handler)。你认为这些 console.log 调用的执行顺序是怎样的?

由于 p 已兑现,你可能预期它先打印 'after:wait' ,然后打印这些 'tick'。而事实上,这就是你在 Node.js 8 中得到的行为。

Node.js 8 中 await 的错误
Node.js 8 中 await 的错误

尽管此行为看起来符合直觉,但根据规范,它并不正确。Node.js 10 实现了正确的行为,即首先执行链接的处理程序,然后才执行异步函数。

Node.js 10 中不再有这个 await 的错误
Node.js 10 中不再有这个 await 的错误

这种*“正确行为”*可以说是隐晦的,实际上也是让 JavaScript 开发者感到惊讶的,所以它值得一些解释。在深入期约和异步函数的神奇世界前,让我们先从一些基础开始。

任务与微任务

在较高层次上,JavaScript 中有任务(task)微任务(microtask)。任务处理像是 I/O 和计数器这样的事件,一次执行一个。微任务为 async/await 和期约实现延迟执行,并在每个任务结束时执行。在其执行返回到事件循环前,微任务队列始终被清空。

任务和微任务之间的区别
任务和微任务之间的区别

更多详细信息请查阅 Jack Archibald 对浏览器中的任务、微任务、队列和调度的解释。

异步函数

根据 MDN 的说法,异步函数是一种使用隐式期约异步地操作返回其结果的函数。异步函数旨在使异步代码看起来像同步代码,从而为开发人员隐藏异步处理的一些复杂性。

最简单的异步函数可能看起来像这样:

async function computeAnswer() {
  return 42;
}

调用它会返回一个期约,之后你可以像是处理任何其他期约一样获取它的值。

const p = computeAnswer();
// → Promise (返回一个期约)

p.then(console.log);
// 将在下一轮微任务执行打印 42

只有下次运行微任务时,你才能获得此期约 p 的值。换言之,上述程序在语义上等同于用该值调用 Promise.resolve

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

异步函数真正的力量来自 await 表达式,它使函数执行暂停,直到期约被解决(resolved),然后在其兑现后恢复执行。await 操作符的值是兑现的期约的值。下面是一个示例,对此进行了解释:

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

fetchStatus 的执行在 await 上被挂起,稍后在 fetch 的期约兑现时恢复。这或多或少等同于将一个处理程序链接到自 fetch 返回的期约上。

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

上面的处理程序包含了之前的异步函数中 await 之后的代码。

通常,你会传递一个 Promiseawait,但你其实可以等待(wait)任何类型的 JavaScript 值。如果在 await 后的表达式的值不是期约,它会被转化为一个期约。这意味着,如果你愿意,可以 await 42

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

const p = foo();
// → Promise (返回一个期约)

p.then(console.log);
// 最终打印 `42`

更有趣的是,await 可以使用任何 “thenable” 值,或者说任何具有 then 方法的对象,即使它不是真正的期约。所以你可以实现一些有趣的事情,像是异步 sleep,测量实际消耗于 sleep 的时间(注:由于内部机制,timeout 不是精准的):

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

一旦调用,它会将参数包装成一个期约,并挂起异步函数的执行,直到期约被解决。一旦期约被解决,函数的执行就会恢复,同时 w 被赋值兑现期约中的值。这个值稍后将从异步函数中返回。

引擎内部的 await

首先,V8 将这个函数标记为 resumable(即可恢复的),意味着函数的执行可以被挂起并在稍后恢复(在 await 点,或等待点)。然后,它创建所谓的 implicit_promise,这是个在你调用此异步函数时返回的期约,最终以异步函数产生的值来解决。

简单异步函数和引擎对其转换结果之间的对比
简单异步函数和引擎对其转换结果之间的对比

接下来是比较有趣的部分了:实际的 await。首先,传递给 await 的值被包装为一个期约 promise。之后,处理程序被附加到这个包装的期约上,用以在期约被兑现后恢复函数,同时异步函数的执行被挂起,返回 implicit_promise 给调用者。一旦这个 promise 被兑现,异步函数的执行将使用这个 promise 的值 w 恢复,并使用 w 解决 implicit_promise

简而言之,await v 的初始步骤是:

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

让我们一步步地查看各个操作。假设正在被 await 的东西已经是一个期约,并且已用值 42 兑现。然后引擎会创建新的期约 promise 并使用被 await 的任何内容来解决它。根据规范里所谓的 PromiseResolveThenableJob 中的表述,这一操作确实推迟了这些期约在下一轮执行中的链接操作。

await 步骤1

然后引擎创建了另一个被称为 throwaway 的期约。其被称为 throwaway 的原因是没有任何东西会被链接到它上面——它完全是引擎内部的。这个 throwaway 期约之后被链接到 promise 上,一同链接的还有适当的处理程序,用以恢复异步函数。这一 performPromiseThen 操作本质是 Promise.prototype.then() 在幕后所做的事。最后,异步函数的执行被挂起,控制返回给调用者。

await 步骤2

执行在调用者中继续,最终调用栈为空。然后 JavaScript 引擎开始运行微任务:它运行先前调度的 performPromiseThen,其将调度一个新的 PromiseReactJob 以将 promise 链接到传给 await 的值上(注:上文已假设值已经是期约了)。然后,引擎返回以处理微任务队列,因为在继续主要的事件循环之前必须清空微任务队列。

await 步骤3

接下来是 PromiseReactionJob,它将使用我们正在 await 的期约中的值,在本例中是 42,去兑现 promise ,并将反应(reaction)安排到 throwaway 期约上。然后,引擎再次返回微任务循环,这之中包含了要处理的最后一个微任务。

await 步骤4,最终步骤

现在,这第二个 PromiseReactionJob 将解决(resolution)传播到了 throwaway 期约(即依次解决期约,然后执行到了 throwaway),并恢复了挂起的异步函数执行,从 await 返回了值 42

await 开销概要
await 开销概要

总结下目前我们了解到的,对于每个 await,引擎必须要创建两个额外的期约(即便右侧已经是一个期约)以及至少三个微任务队列刻度。谁知道一个 await 表达式怎么会导致这么大的开销

之前的 await 代码

让我们看看这个开销是从哪里来的。第一行负责创建包装器期约。第二行立刻使用 await 的值 v 解决包装器期约。这两行就创建了一个额外的期约,外加这三个微任务中的两个。如果 v 已经是一个期约(这很常见,因为应用通常都会 await 期约)。在不太可能出现的情况下,开发人员 await 像是 42 这样的值,引擎仍然需要将其包装为期约。

事实证明,规范中已经有了一个 promiseResolve 操作,仅在需要时执行包装。

await 代码对比

此操作原样返回期约,仅在需要时将其他值包装进期约。借助这种方式,在通常情况下传递给 await 的值已经是一个期约时,我们可以减少一个额外期约的消耗,也不用为微任务队列再添两个刻度了。这一新行为已经在 V8 v7.2 中默认启用。而在 V8 v7.1 中,你可以通过 --harmony-await-optimization 标志启用这一新行为。我们也向 ECMAScript 规范提议了这一修改

以下是新的改进后的 await 在幕后工作方式,逐步地进行描述:

await 新步骤1

让我们再次假设我们 await 了一个已经用 42 兑现的期约。感谢 promiseResolve 的魔法,promise 现在仅引用了这一期约 v,所以此步无需进行任何操作。之后,引擎像之前一样继续执行,创建 throwaway 期约,调度一个 PromiseReactionJob 以在下一个微任务队列上的刻度恢复异步函数,挂起当前函数的执行,并返回到调用者。

await 新步骤2

最终,当所有的 JavaScript 执行完成时,引擎开始运行微任务,所以它执行 PromiseReactionJob。这个任务将 promise 的解决传播给了 throwaway,并恢复异步函数的执行,从 await 生成(yield) 42

await 开销减少的概要
await 开销减少的概要

如果传递给 await 的值已经是期约,此优化将避免了创建包装器(wrapper)期约的需要。在这种情况下,我们从最少需要三个微刻度(microtick,同微任务刻度,或微任务队列上的刻度)到仅需要一个微刻度。这种行为与 Node.js 8 的做法类似,只不过它现在不再是一个错误——现在是正在被标准化的优化了!

尽管完全是在引擎内部,但引擎必须创建这个 throwaway 期约还是感觉有点不对劲。事实证明,throwaway 期约只是为了满足规范中内部 performPromiseThen 操作的 API 约束。

优化后的 await

最近,ECMAScript 规范的编辑性修改解决了此问题。引擎不再需要为 await 创建 throwaway 期约——大部分时间是不用的[2]

node 10 对比 node 12
优化前和优化后 await 代码的对比

将 Node.js 10 中的 await 和 Node.js 12 中可能出现的被优化的 await 进行的比较,展示了此变化对性能的影响:

优化后的基准测试

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

**更新:**自 V8 v7.2 和 Chrome 72 开始,已默认启用 --harmony-await-optimization 。对 ECMAScript 规范的补丁已被合并。

改善开发者体验

除性能外,JavaScript 开发人员还关注诊断和修复问题的能力。要知道,处理异步代码问题时,诊断和修复问题并不总是很容易。Chrome 开发者工具支持异步堆栈追踪(async stack traces),也就是说栈追踪不只包含当前栈的同步部分,同时还有异步部分:

开发者工具

在本地开发过程中,这是一个极其有用的功能。然而,一旦应用被部署,这种方法并不能真正帮到你。在事后调试期间,你只会看到日志文件中的 Error#stack 输出,而这不会告诉你关于异步部分的任何信息。

我们最近一直致力于零成本异步堆栈追踪,它通过异步函数调用丰富了 Error#stack 属性。“零成本”听起来让人兴奋,不是吗?在 Chrome 开发者工具功能带来大量开销时,怎么还能做到零成本的?考虑这个示例,foo 异步调用 bar,并且 barawait 后抛出异常:

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)

在堆栈追踪中,最顶层的函数最先出现,然后是同步堆栈追踪其余的部分,再之后是函数 foo 内对 bar 的异步调用。此变更在 V8 中通过跟随新的 --async-stack-trances 标志实现。**更新:**从 V8 v7.3 开始,--async-stack-traces 默认被启用。

但是,如果你将此与上述 Chrome 开发者工具中的异步堆栈调用对比,你会注意到堆栈追踪的异步部分缺少了实际调用 foo 的位置。就像我们之前提到的,此方法利用了 await 恢复和挂起的位置相同这一事实,但对于常规 Promise#then() 或是 Promise#catch() 调用,是不属于此情况的。有关更多背景知识,请查看 Mathias Bynens 在为什么 await 击败了 Promise#then() 中的解释。

结论

通过两项重要的优化,我们使得异步函数更快了:

  • 对两个额外的微刻度的移除,以及
  • throwaway 期约的移除。

最重要的是,我们通过零成本异步堆栈追踪改善了开发人员的体验,该追踪可以与异步函数中的 awaitPromise.all() 配合使用。

我们还有些给 JavaScript 开发者的不错性能建议:

  • 优先使用 async 函数和 await 而不是手写的期约代码,同时
  • 坚持使用 JavaScript 引擎提供的原生期约实现,以从简化中受益,也就是我们所说的对于 await 两个微刻度的回避。

脚注

  1. [1]:感谢 Matteo Collina 为我们指出这个问题

  2. [2]:如果在 Node.js 中使用 async_hooks,V8 仍需要创建 throwaway 期约,因为 beforeafter 钩子在 throwaway 期约的上下文中运行。


部分翻译对照表

部分翻译对照表
原文翻译备注
promise期约按 JavaScript 高级程序设计名词翻译
async function(s)异步函数
async programming异步编程
asynchronous code异步代码code 作名词,译为代码
the control and data flow控制流和数据流
async iteration异步迭代
asynchronous paradigm异步范式
chunk(s)
the end-of-stream signaling流结尾信号传递
bug(程序)错误
production生产(环境)
programming paradigms编程范式
parallel benchmark并行基准测试
synthetic micro-benchmarks人造的微基准测试
promise-heavy重度使用期约(的)
performance improvements性能优化
microtick微任务刻度指执行一个微任务的时间,类似游戏服务器的 tick
optimizing compiler优化编译器
garbage collector(内存)垃圾收集器出自《计算机科学名词》第三版
fulfilled(被)兑现(的)
chain链接
handler处理程序
arguably not immediately obvious可以说是隐晦的
suspend挂起
resume恢复
task任务
microtask微任务
resolved被解决指期约从初始态转换为 fulfilled 或 rejected 两种最终态
under the hood在内部,这里翻译为在引擎内部
await pointsawait 点/等待点类似于 breakpoint 的概念,即进行 await 的地方,这里如果使用“行内代码标志则不译
attach附加
call stack调用栈对执行上下文栈的别名,或是抽象
schedule调度/安排安排任务到队列译为调度,其他情况一般为安排
reaction反应这里特指链接到期约上的回调,规范中的指代名词
resolution解决resolve 的名词形式,指代 resolve 行为
microtask queue ticks微任务队列刻度意即一次微任务出队并执行
refer to引用
yield生成
wrapper包装器
microtick微刻度微任务刻度或微任务队列上的刻度的简称
editorial change编辑性修改标准学术语,不改变技术内容的修改
async stack traces异步堆栈追踪
post-mortem debugging事后调试即发布应用后在上线环境中的调试
hand-written promise code手写的期约代码