はじめに
現在進行中のプロジェクトでは、Minecraft統合版BDSサーバーのWeb管理アプリを開発しています。
BDSを1つのインスタンスとして扱い、起動・停止・自動アップデートなどをWeb GUI上で操作できるようにしています。
その中で避けて通れなかったのが「大量ファイルのコピー処理」です。
fs/promisesやfs.cp()で簡単にコピーできるものの、数千単位のファイルを扱うと急に遅くなる・CPUが暴走するといった課題が見つかりました。
そこで今回は、約8,000ファイルを対象に直列処理・並列処理・Promiseリミット制御を比較実験し、
Node.jsで最適なファイルI/Oパフォーマンスを引き出す方法を検証しました。
📁 実験環境
| 項目 | 内容 |
|---|---|
| CPU | Ryzen 7 5700U |
| ストレージ | NVMe SSD |
| ファイル数 | 約8,373 |
| 合計サイズ | 約305MB |
| Node.js | v22.18.0 |
| OS | Ubuntu 24.04.3 LTS |
⚙️ 目的と背景
fs.promisesを使うだけでは、ファイル数が多い環境で次のような問題が発生します。
- 直列処理:遅い(I/O待ち時間が支配的)
- 無制限並列:速いがCPU過負荷・クラッシュリスク
つまり、「どこまで並列すれば最も速く、安定するか」 を探るのが今回の目的です。
✅ 直列処理版
for (const entry of entries) {
if (entry.isDirectory()) {
await this.copyDir(srcPath, destPath);
} else {
await this.copyStream(srcPath, destPath);
}
}
単純で安全ですが、I/O待ちが直列化するため非常に遅くなります。
🚀 並列処理への転換
p-limitを導入してPromiseの同時実行数を制御しました。
import pLimit from "p-limit";
const limit = pLimit(10); // 最大10並列
const tasks = entries.map((entry) =>
limit(async () => {
const srcPath = join(validSrc, entry.name);
const destPath = join(validDest, entry.name);
if (entry.isDirectory()) {
await this.copyDir(srcPath, destPath);
} else {
await this.copyStream(srcPath, destPath);
}
})
);
await Promise.all(tasks);
📊 ベンチマーク結果
| 並列リミット | 実行時間 | CPU使用率(Nodeプロセス) | 備考 |
|---|---|---|---|
| 1(直列) | 約3.00s | 約34% | 意外と高速だがI/O律速 |
| 5 | 約3.50s | 約50% | 並列で負荷上昇、時間変化なし |
| 10 | 約3.29s | 約215%(全体45%) | 最適バランス |
| 20 | 約3.23s | 約290% | 高負荷、改善効果ほぼなし |
🔬 考察
- NVMe環境では I/O待ちよりもCPUオーバーヘッドが支配的
- 並列度を上げても速度が頭打ちになり、CPU使用率だけが上昇
- 無制限Promiseは危険(クラッシュ・GC多発)
⚠️ リスク
大量のPromiseを発行、特に数千ファイルを扱う際は数千のPromiseが発行すると以下のようなリスクが発生する。
-
EMFILEエラー
→ OSのファイルディスクリプタ上限を超える -
GC遅延・ヒープ圧迫
→ 数千Promiseの同時生成でV8 GCが頻発 -
スレッドプール飽和
→ Node.js内部のlibuvスレッドプールが詰まる
🧠結論
| 結論 | 内容 |
|---|---|
| ✅ 最適な並列数 | 10(CPUとI/Oのバランスが最良) |
| ⚙️ fsスレッドプール | デフォルト設定で十分 |
| 🧯 安全策 | Promiseリミット+例外処理+ログ監視 |
| 🚫 非推奨 | 無制限のPromise.all()(クラッシュリスク大) |
まとめ
- Node.jsで大量のファイルを扱う際は、「無制限並列=速い」ではない
- 実際にはCPU・I/O・スレッドプールのバランスが大事
- Promiseのリミットを10前後に設定することで、高速かつ安定したファイルコピー処理が実現できる
今回、Node.jsでファイルを操作したのは初めてでした。ファイル操作を実装する過程では気にしないといけないポイントが多く、実装には相当な時間がかかりましたが、自分の技術力がさらにレベルアップし非常にためになる経験でした。
おまけ
今回BDSのファイルI/Oを担当するクラスObsidianIOServiceは以下の特徴を持っています。
- 絶対パス検証(プロジェクト外のアクセス防止)
- ストリームコピー対応(大容量のファイルでも安定)
- ディレクトリ再帰コピー
-
p-limitによる並列制御
実装全体は記事末尾のGitHubリンクをご参照ください。
おわりに
今回の検証を通して 「Node.jsでのI/Oの最適化はCPU・メモリの知識と密接に関係する」 ことを痛感しました。
結果的に、ファイルI/Oを理解する過程で自分のコーディングスキルも一段上がったと感じています。
今回の学び
今回初めてパフォーマンスチューニングに取り組みましたが、ただ動くだけのプログラムから「どうすればより速く・効率的に動くか」を考えるのはとても楽しく、開発の新しい楽しさを実感できました。今後も理論と実験を繰り返しながら、より質の高いコードを書けるように成長していきたいと思います。