前言
在今年 11 月份 Node.js 发布 v8.x LTS 版本之后,终于可以不用借助额外的工具就可以使用 ESNext 标准中的 async function
来进行异步编程,彻底改变了我们的编程习惯。以前被人诟病的 回调地狱 将不复存在,而且再也不需要使用 Generator 这种 _撇脚_ 的方式了(请阅读我于去年写的文章《基于 Generator 与 Promise 的异步编程解决方案》)。
在此之前,为了在 Node v6.x 及更早的版本中使用 async function
,我们需要使用 Babel 或者 TypeScript 这样的工具将代码转换成使用 callback
或者 Generator
的方式。而 根据以往的经验,这些被转换过的代码执行效率都比较低,比如 Node v4.x 以前的 Generator 和之后的 原生 Promise。因此,为了让我们更有信心地使用 async function
,我做了一些简单的性能测试,请看下文。
测试方法与代码
原始的代码使用 TypeScript 编写,然后通过命令 tsc --target esnext|es6|es5
将其分别转换为不同的目标代码(文件:build.sh):
- ESNext - 使用
async function
方式,文件:esnext.js - ES6 - 使用
Generator
方式,文件:es6.js - ES5 - 使用
callback
方式(注意:使用此方式会生成很多无用的代码,其主要是用于模拟 Generator,比我们自己手写的 callback 代码低效很多),文件:es5.js
被编译后的代码会分别在 Node.js 各个主要版本上执行:v4.8.7、6.12.2、8.9.3 和 9.3.0(为了描述方便,下文会简单描述为 4.x、6.x、8.x 和 9.x),其中由于 v4.x 和 v6.x 不支持 async function
则不需要执行该测试。执行花费的时间取开始和结束的 process.uptime()
之差(单位为秒),内存占用取 process.memoryUsage().rss / 1000000
(单位为 MB)。
以下是 TypeScript 源码:
"use strict";
function add(n): Promise<number> {
return new Promise((resolve, reject) => {
resolve(n + 1);
});
}
async function call() {
const a = await add(1);
const b = await add(2);
const c = await add(3);
const d = await add(4);
return a + b + c + d === 14;
}
async function test(n: number) {
const version = `node ${process.version}`;
const name = __filename
.split(/\\|\//)
.pop()
.slice(0, -3);
const promise =
Promise.toString().indexOf("[native code]") !== -1
? "ES6 Promise"
: "bluebird Promise";
const title = `${version} ${name} with ${promise} - test ${n} times`;
const time = process.uptime();
for (let i = 0; i < n; i++) {
await call();
}
console.log(
"%s - %ds - %dMB",
title,
(process.uptime() - time).toFixed(2),
(process.memoryUsage().rss / 1000000).toFixed(1)
);
}
const K = 1000;
let num = parseInt(process.env.NUM, 10);
if (isNaN(num) || !(num > 0)) {
num = 100;
}
test(num * K);
说明:
add()
模拟一个简单的异步操作,通过Promise
实现,返回输入参数n
的值加1
call()
模拟一次异步调用流程,包含4
次add()
异步操作test()
是测试流程控制,包含循环多次测试call()
并返回计算总花费时间和内存占用,并打印出结果
测试结果分析
我们首先看看执行 100 万次 call()
时,使用 callback
和 Generator
在各个版本上执行的情况:
Node.js 版本 | 异步方式 | 花费时间(s) | 内存占用(MB) |
---|---|---|---|
v4.x | callback | 45 | 238.9 |
同上 | Generator | 24.02 | 71.5 |
v6.x | callback | 20.02 | 61.9 |
同上 | Generator | 22.62 | 69 |
v8.x | callback | 5.57 | 85.8 |
同上 | Generator | 5.72 | 96.3 |
v9.x | callback | 6.4 | 91.9 |
同上 | Generator | 6.59 | 96.8 |
由上表可以看出,从 v6.x 到 v8.x 其花费的时间和内存占用都降低了很多(v9.x 由于是非稳定版本的原因,测试结果比 v8.x 差是可以理解的),说明新版本的 Node.js 性能都有了很大的提升。
我们关心的第一个问题是:直接使用 async function 会比转成相应的 Genrator 代码高效吗?
Node.js 版本 | 异步方式 | 花费时间(s) | 内存占用(MB) |
---|---|---|---|
v4.x | Generator | 24.02 | 71.5 |
v6.x | Generator | 22.62 | 69 |
v8.x | Generator | 5.72 | 96.3 |
同上 | async | 1.76 | 31.5 |
v9.x | Generator | 6.59 | 96.8 |
同上 | async | 1.91 | 31.5 |
答案是肯定的。在 Node v8.x 上使用 async function 比转成相应的 Generator 代码执行性能提高了 3 倍。
一直以来我们都有一个印象,原生 Promise 性能很差,一般会使用 bluebird
这种第三方 Promise 实现来代替。那么第二个问题来了:使用 bluebird 代替原生 Promise 会不会更高效?
(方法:执行测试程序前通过 global.Promise = require("bluebird")
替换全局的 Promise 对象)
Node.js 版本 | 异步方式 | 花费时间(s) | 内存占用(MB) |
---|---|---|---|
v4.x | Generator | 10.54 | 86.2 |
v6.x | Generator | 8.17 | 78.9 |
v8.x | Generator | 5.46 | 90.3 |
同上 | async | 16.25 | 34 |
v9.x | Generator | 5.83 | 93.7 |
同上 | async | 15.39 | 34.7 |
对比上面两张表格可以看出,在 v4.x 和 v6.x 的时候,使用 bluebird 差不多有 2 倍的性能提升。但是,在 v8.x 之后却是相反的。因此,Node.js v8.x 的原生 Promise 已经得到了很大的优化,可以不需要使用 bluebird 这样的第三方 Promise 库;如果使用了 async function,替换原生的 Promise 反而会大大降低性能。
好了,最最关键的问题来了:使用 async function 与 callback 方式做同样的事情性能相差多少?
以下是我将上文的代码 经过简单的转换而成的 callback 写法:
"use strict";
function add(n, callback) {
process.nextTick(() => callback(null, n + 1));
}
function call(callback) {
add(1, (err, a) => {
if (err) return callback(err);
add(2, (err, b) => {
if (err) return callback(err);
add(3, (err, c) => {
if (err) return callback(err);
add(4, (err, d) => {
if (err) return callback(err);
callback(null, a + b + c + d === 14);
});
});
});
});
}
function test(n) {
const version = `node ${process.version}`;
const name = __filename
.split(/\\|\//)
.pop()
.slice(0, -3);
const title = `${version} ${name} with callback - test ${n} times`;
const time = process.uptime();
const done = () => {
console.log(
"%s - %ds - %dMB",
title,
(process.uptime() - time).toFixed(2),
(process.memoryUsage().rss / 1000000).toFixed(1)
);
};
let i = 0;
const next = err => {
if (err) throw err;
if (i < n) {
i++;
process.nextTick(() => call(next));
} else {
done();
}
};
next();
}
const K = 1000;
let num = parseInt(process.env.NUM, 10);
if (isNaN(num) || !(num > 0)) {
num = 100;
}
test(num * K);
说明:
- 所有异步函数的回调函数格式为
callback(err, ret)
,如果没有错误则err = null
,ret
表示返回值 - 所有异步回调第一行都会检查
if (err) return callback(err)
,虽然看起来啰嗦,但是要尽量模拟真是的场景 - 每次执行
call()
都需要放在process.nextTick()
回调函数里面,主要是用于模拟一个异步操作,否则程序会因为调用堆栈过深而报错
以下是执行结果:
Node.js 版本 | 花费时间(s) | 内存占用(MB) |
---|---|---|
v4.x | 0.64 | 54.8 |
v6.x | 0.65 | 54.8 |
v8.x | 1.04 | 27.5 |
v9.x | 1.03 | 27.5 |
使用 async function
编写的代码在 Node.js v8.x 花费的时间是 1.76s,内存占用是 31.5MB,与使用 callback
编写的代码相比,数值相差并不大(执行 100 万次时间相差不足 1 秒),属于可以接受范围。
结论
以上可以算是一个 不太严谨 的测试方案,并没有全面地测试不同实现方式对结果的影响,也没有重复执行多次的测试来尽量减少结果偏差。但无论怎样,通过这些测试结果我们还是可以知道:
- 从 v4.x 到 v8.x,Node.js 的整体性能有了很大的提升
- Node.js v8.x 的原生 Promise 已经足够快,不再需要使用 bluebird 来代替了
- 直接执行 async function 比转换成相应的 Generator 代码要快很多
- 使用 async function 编写代码跟使用 callback 方式编写代码之间的性能差异已经很小了,而 async function 的代码往往更直观
新的 ES 语法大大简化了异步编程的难度,而随着 Node.js 版本的升级,刚开始担心的那些性能问题也终将化为浮云。
所以,如果你已经用上了 Node.js v8.x,而且不需要兼容老的 Node.js 版本,可以放心大胆地使用 async function 啦。
相关链接
- 6 Reasons Why JavaScript’s Async/Await Blows Promises Away (Tutorial)
- Performance of native ES2015 promises and ES2017 async functions in Node.js v8
- Using Async Await in Express with Node 9
- Explaining async await of ES8 with examples
- 基于 Generator 与 Promise 的异步编程解决方案
- Node.js 性能调优之代码篇(一)——使用原生模块
- Node.js 性能调优之代码篇(三)—— 升级到 Node@8