本文详细的讲解了如何在V8中优化JavaScript异步编程,从最开始的回调到promise再到异步函数,清晰的分析了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)之间的版本上显著提升了异步代码的性能。我们达到了一定的性能水平,开发人员可以安全地使用这些新的编程范例,无需担心速度问题。


- TurboFan(https://v8.dev/docs/turbofan),新的优化编译器;
- Orinoco(https://v8.dev/blog/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 中你会得到这样的行为:


任务与微任务
在高层面看,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。
- 将 v——传给 await 的值——包装成 promise;
- 附加处理程序以便稍后恢复异步函数;
- 挂起异步函数,并将 implicit_promise 返回给调用者。












改进的开发者体验
除了性能之外,JavaScript 开发者还关心诊断和修复 bug 方面的问题,这些在处理异步代码时并不总是那么容易。Chrome DevTools 支持异步堆栈跟踪,堆栈跟踪不仅包括堆栈的当前同步部分,还包括异步部分:
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。
- 使用异步函数和 await 代替手写的 promise 代码;
- 坚持使用 JavaScript 引擎提供的原生 promise 实现,这样可以避免为 await 使用两个 microtick。