码迷,mamicode.com
首页 > 编程语言 > 详细

如何在V8中优化JavaScript异步编程?

时间:2020-12-25 12:47:40      阅读:0      评论:0      收藏:0      [点我收藏+]

标签:turn   nal   方式   error:   包括   com   测试   art   调试   

如何在V8中优化JavaScript异步编程?

技术图片
作者|V8 引擎博客
译者|无明
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 中你会得到这样的行为:
![](https://s4.51cto.com/images/blog/202012/20/80609580e3d353e3ab95bcf8e985bfc7.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
虽然这种行为看起来很直观,但根据规范,它是不对的。Node.js 10 实现了正确的行为,应该首先执行被链接的处理程序,然后再调用异步函数。
![](https://s4.51cto.com/images/blog/202012/20/101daae50ef3b2d158b6ccc576e136e2.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
这种“正确的行为”可以说并不是很明显,JavaScript 开发人员可能会感到惊讶,所以这里需要解释一下。在我们深入了解 promise 和异步函数之前,让我们从一些基础知识开始。
## 任务与微任务
在高层面看,JavaScript 中存在任务和微任务。任务负责处理 I/O 和计时器等事件,每次执行一个。微任务实现了 async/await 和 promise 的延迟执行,并且是在每个任务结束时执行。在执行返回到事件循环之前,微任务队列会被清空。
![](https://s4.51cto.com/images/blog/202012/20/f348486e1813c15d822a23a679ac6d19.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
## 异步函数
根据 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。
![](https://s4.51cto.com/images/blog/202012/20/467b1932fdcacb13b6275be53daab862.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
然后是最有趣的部分:实际的 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 的东西来描述。
![](https://s4.51cto.com/images/blog/202012/20/c82ffefe5cc6c1bf217281d882c80cb9.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
然后引擎创建了另一个所谓的 throwaway promise。它之所以被叫作 throwaway,是因为它没有链接任何东西——它完全在引擎内部。然后这个 throwaway 被链接到 promise 上,使用适当的处理程序来恢复异步函数。performPromiseThen 操作基本上是 Promise.prototype.then() 做的事情。最后,异步函数的执行被暂停,控制权返回给了调用者。
![](https://s4.51cto.com/images/blog/202012/20/9059c602faebc556af36ff6fdd3c5dba.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
执行在调用者中继续,直到调用栈变空。然后 JavaScript 引擎开始运行微任务:它运行先前调度的 PromiseResolveThenableJob,它会调度新的 PromiseReactionJob,并将 promise 链接到传给 await 的值。然后,引擎返回去处理微任务队列,因为在继续主事件循环之前必须清空微任务队列。
![](https://s4.51cto.com/images/blog/202012/20/8f41ab88dbe9cb65a1d5f0cd738aa3df.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
接下来是 PromiseReactionJob,它完成了 promise,值为 42,并调度 throwaway promise。然后,引擎再次返回微任务循环,其中包含了最终需要处理的微任务。
![](https://s4.51cto.com/images/blog/202012/20/dda5962d5eb94d8cb9db8caf3b4444d5.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
现在,第二个 PromiseReactionJob 完成了 throwaway promise,并恢复异步函数执行,从 await 返回 42。
![](https://s4.51cto.com/images/blog/202012/20/4860d9a5fa37265c2efdeddd5cf43b33.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
对于每个 await,引擎都必须创建两个额外的 promise,并需要至少三个微任务。谁会想到一个 await 表达式会导致这么多的开销?!
我们来看看这些开销来自哪里。第一行负责创建包装器 promise,第二行立即用 await 值 v 完成了包装器 promise。这两行负责一个额外的 promise 加上三个微任务中的两个。如果 v 是一个 promise(这是很常见的情况,因为应用程序通常会 await promise),开销就非常大。比如,开发人员在 42 上 await,那么引擎仍然需要将它包装成一个 promise。
事实证明,规范中已经有一个 promiseResolve 操作,它只在必要时执行包装:
![](https://s4.51cto.com/images/blog/202012/20/4f63a486c3a88c1dd60afdf099262d51.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
这个操作照原样返回 promise,并且只在必要时将其他值包装成 promise。当传给 await 的值已经是 promise 时,你可以通过这种方式保存其中一个额外的 promise,以及微任务队列上的两个任务。这种新行为已经通过 V8 中的 --harmony-await-optimization 标志实现了(从 V8 v7.1 开始)。我们也向 ECMAScript 规范提交了这个变更提案,在我们确定它与 Web 兼容后,就应该会合并补丁。
以下是新的改进后的 await 的执行步骤:
![](https://s4.51cto.com/images/blog/202012/20/84eb9ddeb2e5599f9872e2f13c1e417c.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
让我们再次假设我们 await 一个结果为 42 的 promise。因为有了 promiseResolve,现在 promise 引用的是相同的 promise v,所以在这一步没什么可做的。之后,引擎将继续像以前一样,创建 throwaway promise,调度 PromiseReactionJob 以便在后面恢复异步函数、暂停函数的执行,以及返回给调用者。
![](https://s4.51cto.com/images/blog/202012/20/42c6241ada0c23e7fd3e0a5ef43e2250.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
最后,当所有 JavaScript 执行完成时,引擎开始运行微任务,这个时候会执行 PromiseReactionJob。这个作业将 promise 的结果传播给 throwaway,并恢复异步函数的执行,await 将生成 42。
![](https://s4.51cto.com/images/blog/202012/20/8ee937bb5d2826c08202a81ca6e97dd8.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
如果传给 await 的值已经是一个 promise,那么就可以避免创建包装器 promise,在这种情况下,我们只需要一个 microtick。这种行为类似于 Node.js 8 所做的,只是现在它不再是一个 bug,而是一个正在被标准化的优化方案!
引擎仍然会创建 throwaway promise,尽管是在内部,但感觉不太对。事实证明,throwaway promise 只是为了满足规范对内部 performPromiseThen 操作的 API 约束。
![](https://s4.51cto.com/images/blog/202012/20/707e56868fbb6911adaf9c948155ea99.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
这个问题在最近的 ECMAScript 规范编辑性变更中得到了解决。在多数情况下,引擎不再需要为 await 创建 throwaway promise。
![](https://s4.51cto.com/images/blog/202012/20/cb1e59acb3d60616e0f12a2cd2fb0c44.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
将 Node.js 10 中的 await 与可能在 Node.js 12 中发布的优化版 await 进行比较,显示了这个变更对性能的影响:
![](https://s4.51cto.com/images/blog/202012/20/b6df2e91b1ffbe5b2ecab5273cdad3a9.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
async/await 现在优于手写的 promise 代码。这里的关键点是我们通过修改规范显著减少了异步函数的开销——不仅在 V8 中,而且是在所有的 JavaScript 引擎中。
## 改进的开发者体验
除了性能之外,JavaScript 开发者还关心诊断和修复 bug 方面的问题,这些在处理异步代码时并不总是那么容易。Chrome DevTools 支持异步堆栈跟踪,堆栈跟踪不仅包括堆栈的当前同步部分,还包括异步部分:
![](https://s4.51cto.com/images/blog/202012/20/29c2dd1f977a7c958b3c87448339a1df.png?x-oss-process=image/watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=)
在进行本地开发时,这是非常有用的一项功能,但对于已经部署的应用程序,这种方法并不会真正帮助到你。在进行事后调试时,你只会在日志文件中看到 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

如何在V8中优化JavaScript异步编程?

标签:turn   nal   方式   error:   包括   com   测试   art   调试   

原文地址:https://blog.51cto.com/15057848/2568185

(0)
(0)
   
举报
评论 一句话评论(0
登录后才能评论!
© 2014 mamicode.com 版权所有  联系我们:gaon5@hotmail.com
迷上了代码!