Node 8:迎接 async await 新时代

老雷 创作于 2017-12-15
Node.js Async 全文约 4822 字,预计阅读时间为 13 分钟

前言

在今年 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):

被编译后的代码会分别在 Node.js 各个主要版本上执行:v4.8.76.12.28.9.39.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);

说明:

测试结果分析

我们首先看看执行 100 万次 call() 时,使用 callbackGenerator 在各个版本上执行的情况:

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

说明:

以下是执行结果:

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 秒),属于可以接受范围

结论

以上可以算是一个 不太严谨 的测试方案,并没有全面地测试不同实现方式对结果的影响,也没有重复执行多次的测试来尽量减少结果偏差。但无论怎样,通过这些测试结果我们还是可以知道:

新的 ES 语法大大简化了异步编程的难度,而随着 Node.js 版本的升级,刚开始担心的那些性能问题也终将化为浮云。

所以,如果你已经用上了 Node.js v8.x,而且不需要兼容老的 Node.js 版本,可以放心大胆地使用 async function 啦。

相关链接