Node.js で同期版の API を使った方が速い時がある

このサイトのビルドスクリプトは、自作の Node.js スクリプトを使っている。テンプレートとなる1つの HTML ファイルを読み込み、プレースホルダ部分を置換して各ページを生成したりしている。

当初は fs.promises (非同期版の fs モジュール) を使って、Promise や asyncawait を多用したスクリプトとして作っていたが、何か遅い気がして色々試したところ、fs.readFileSync()fs.writeFileSync() といった同期版の API を使った方が、動作が速いことが分かった。明らかに早くビルドが終わるので、今は同期版の API を使っているのだが、イマイチその理屈が分かっていない。

検証用コード

僕が体感した動作速度の差を皆さんも検証できるように、簡単なスクリプトを書いてみた。fsfs.promises モジュールを使って、3パターンの実装をしてみた。いずれも、TEMPLATE.html というファイルを読み込み、そのファイル中の {{ serial }} というプレースホルダ文字列を検知して文字列置換し書き込む、という処理だ。変数宣言や for ループの回し方など、API と Promise の扱いの違い以外の差異がなるべく出ないように実装している。

const begin = process.hrtime();  // パフォーマンス計測用

const fs = require('fs');
const number = 1000;  // ファイルの生成個数
const directory = 'sync';  // 書き込み先ディレクトリ

const template = fs.readFileSync('./TEMPLATE.html', 'utf-8');  // テンプレートファイルを読み込む
for(let i = 0; i < number; i++) {
  const replaced = template.replace((/\{\{ serial \}\}/gu), i);  // プレースホルダ文字列を連番 i に置換する
  fs.writeFileSync(`./${directory}/${i}.html`, replaced, 'utf-8');
}

const end = process.hrtime(begin);
console.log(`${end[1] / 1000000} ms`);
const begin = process.hrtime();

const fs = require('fs').promises;
const number = 1000;
const directory = 'promise-all';

fs.readFile('./TEMPLATE.html', 'utf-8')
  .then((template) => {
    const promises = [];
    for(let i = 0; i < number; i++) {
      // 置換とファイル書き込み処理の全体を Promise でラップするためこのように書いてみた
      promises.push(new Promise((resolve) => {
        const replaced = template.replace((/\{\{ serial \}\}/gu), i);
        fs.writeFile(`./${directory}/${i}.html`, replaced, 'utf-8').then(() => { resolve(); });
      }));
    }
    return Promise.all(promises);  // 並列実行
  })
  .then(() => {
    const end = process.hrtime(begin);
    console.log(`${end[1] / 1000000} ms`);
  });
const begin = process.hrtime();

const fs = require('fs').promises;
const number = 1000;
const directory = 'async-await';

(async () => {
  const template = await fs.readFile('./TEMPLATE.html', 'utf-8');
  for(let i = 0; i < number; i++) {
    const replaced = template.replace((/\{\{ serial \}\}/gu), i);
    await fs.writeFile(`./${directory}/${i}.html`, replaced, 'utf-8');
  }
  
  const end = process.hrtime(begin);
  console.log(`${end[1] / 1000000} ms`);
})();

…こんな感じ。検証の仕方が甘々かとは思うが、僕が体感した速度差はこうしたコードで感じられると思う。

ご自身で検証される際は、次のような環境リセット用処理を書いておくと楽かも。

const fs = require('fs');
['sync', 'promise-all', 'async-await'].forEach((directory) => {
  fs.rmdirSync(`./${directory}`, { recursive: true, force: true });
  fs.mkdirSync(`./${directory}`, { recursive: true });
});

測定結果と考察

適当なテンプレート HTML ファイルと、ファイル出力先となる空ディレクトリを予め用意しておき、3つのスクリプトを実行してみると、こんな感じで実行速度に差が出た。

# 同期版 API で直列処理
$ node sync.js
35.9836 ms

# Promise.all で並列処理
$ node promise-all.js
126.4012 ms

# async・await で直列処理
$ node async-await.js
196.7749 ms

同期版 API の sync.js が明らかに一番速い。Promise.all で並列処理したモノはその4倍近く遅く、asyncawait を使ったモノは5倍程度の遅さだ。

asyncawait を使っただけで直列実行しているパターンが、同期版よりも遅くなるのは何となく分かる。直列的な処理なのに Promise でラップされているので、イベントループを回りまくってその分遅くなるのだろう。いやしかしそれでも、Promise.all 版よりココまで遅くなるとは…。

そして、Promise.all による並列処理も、こんなに遅いものか、というのは意外だ。ただ、その理屈も説明はできるかもしれない。

Node.js はシングルスレッドで動作する。Promise.all はマルチスレッドを使った「並列」処理ではなく、非同期処理を順不同に行っているだけの「並行」処理、と呼ぶ方が正確なのだ。つまり、シングルスレッドで複数の操作をちょっとずつ行うので、コンテキストスイッチがかかっていて遅くなるものと思われる。

しかし、それにしても、ココまで遅くなりますか…?というか、同期版の処理がそんなに速いですか。1つずつ順番に処理させる方が速いんですな。

サーバサイドプログラムでコレをやっちゃダメだけど

「なんだ、readFileSyncwriteFileSync の方が速いなら、今度から sync 系の API だけ使うわ」という考え方はマズい。コレは、よくいわれる「ブロッキング I/O」「ノンブロッキング I/O」という特性の違いによるからだ。

「リクエストを元にファイルを書き込む」といったウェブアプリケーションを作成する時、ファイルの書き込み処理に writeFileSync を使ってしまうと、一つのリクエストでファイル書き込み処理をしている間、その他のリクエスト処理がブロックされ、一切動かなくなってしまう。前述の検証コードで感じた速度差の話は置いておいて、ウェブアプリケーションにおいては、「リクエスト A に対するファイル書き込み処理中もリクエスト B の処理を受け付けられるようにしておく」というノンブロッキング処理で実装しておく必要がある。

…しかし、ビルドスクリプトのように一人のユーザが実行するだけのコードなら、別にブロッキング I/O でも困ることはない。そして今回検証したように、同期関数の方がコレほど速く動くというのは意外だった。

こうなると、Node.js でより高速に実行する方法はないのか、興味本位で気になってくる。以前、Cluster モジュールを使ってマルチコア起動してみたことがあったが、同期関数を CPU スレッドの数だけ分割して実行できたら、完全に並列処理になったりする?

最近、CLI ツールを開発している人達が続々と Rust に移行している様子は見えていて、本当に並列処理とか高速化とか考え始めると、Node.js を離れていくのかしら。フロントエンドと同じ JavaScript で実装できる気軽さから、長らく Node.js を使っているけど、自分の中でマンネリ化してきたこともあり、違う言語を本格的に始めてみようかなーとか思い始めている。