Node.js で同期版の API を使った方が速い時がある
このサイトのビルドスクリプトは、自作の Node.js スクリプトを使っている。テンプレートとなる1つの HTML ファイルを読み込み、プレースホルダ部分を置換して各ページを生成したりしている。
当初は fs.promises
(非同期版の fs
モジュール) を使って、Promise や async
・await
を多用したスクリプトとして作っていたが、何か遅い気がして色々試したところ、fs.readFileSync()
や fs.writeFileSync()
といった同期版の API を使った方が、動作が速いことが分かった。明らかに早くビルドが終わるので、今は同期版の API を使っているのだが、イマイチその理屈が分かっていない。
検証用コード
僕が体感した動作速度の差を皆さんも検証できるように、簡単なスクリプトを書いてみた。fs
か fs.promises
モジュールを使って、3パターンの実装をしてみた。いずれも、TEMPLATE.html
というファイルを読み込み、そのファイル中の {{ serial }}
というプレースホルダ文字列を検知して文字列置換し書き込む、という処理だ。変数宣言や for
ループの回し方など、API と Promise の扱いの違い以外の差異がなるべく出ないように実装している。
sync.js
readFileSync
とwriteFileSync
を使っている、完全な同期型の実装- ファイル書き込みも1ファイルずつ直列で処理している
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`);
promise-all.js
fs.promises
を使い、ファイル書き込み処理部分を並列実行している- 並列数が多すぎると
too many open files
エラーが出てしまう。graceful-fs
はこのエラーを検知した時に並列実行数を分割したりとかしてくれるらしい Promise.all
は使い慣れていなくて、配列をどんな風に作ってやると良いのかイマイチセオリーが分かってない…w
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`);
});
async-await.js
fs.promises
とasync
・await
を使い、sync.js
(同期版) とほぼ同じ構成で実装した- Promise を使っているものの、処理自体は1ファイルずつ直列実行している
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倍近く遅く、async
・await
を使ったモノは5倍程度の遅さだ。
async
・await
を使っただけで直列実行しているパターンが、同期版よりも遅くなるのは何となく分かる。直列的な処理なのに Promise でラップされているので、イベントループを回りまくってその分遅くなるのだろう。いやしかしそれでも、Promise.all
版よりココまで遅くなるとは…。
そして、Promise.all
による並列処理も、こんなに遅いものか、というのは意外だ。ただ、その理屈も説明はできるかもしれない。
Node.js はシングルスレッドで動作する。Promise.all
はマルチスレッドを使った「並列」処理ではなく、非同期処理を順不同に行っているだけの「並行」処理、と呼ぶ方が正確なのだ。つまり、シングルスレッドで複数の操作をちょっとずつ行うので、コンテキストスイッチがかかっていて遅くなるものと思われる。
しかし、それにしても、ココまで遅くなりますか…?というか、同期版の処理がそんなに速いですか。1つずつ順番に処理させる方が速いんですな。
サーバサイドプログラムでコレをやっちゃダメだけど
「なんだ、readFileSync
や writeFileSync
の方が速いなら、今度から sync
系の API だけ使うわ」という考え方はマズい。コレは、よくいわれる「ブロッキング I/O」「ノンブロッキング I/O」という特性の違いによるからだ。
「リクエストを元にファイルを書き込む」といったウェブアプリケーションを作成する時、ファイルの書き込み処理に writeFileSync
を使ってしまうと、一つのリクエストでファイル書き込み処理をしている間、その他のリクエスト処理がブロックされ、一切動かなくなってしまう。前述の検証コードで感じた速度差の話は置いておいて、ウェブアプリケーションにおいては、「リクエスト A に対するファイル書き込み処理中もリクエスト B の処理を受け付けられるようにしておく」というノンブロッキング処理で実装しておく必要がある。
…しかし、ビルドスクリプトのように一人のユーザが実行するだけのコードなら、別にブロッキング I/O でも困ることはない。そして今回検証したように、同期関数の方がコレほど速く動くというのは意外だった。
こうなると、Node.js でより高速に実行する方法はないのか、興味本位で気になってくる。以前、Cluster モジュールを使ってマルチコア起動してみたことがあったが、同期関数を CPU スレッドの数だけ分割して実行できたら、完全に並列処理になったりする?
最近、CLI ツールを開発している人達が続々と Rust に移行している様子は見えていて、本当に並列処理とか高速化とか考え始めると、Node.js を離れていくのかしら。フロントエンドと同じ JavaScript で実装できる気軽さから、長らく Node.js を使っているけど、自分の中でマンネリ化してきたこともあり、違う言語を本格的に始めてみようかなーとか思い始めている。