Node.js の Child Process 研究 : fork・exec・execFile・spawn の違いをサンプルコードとともに検証

Node.js の組み込みモジュール、child_process。基本的には、実行中の node プロセスとは別のプロセスを生成する関数が揃っているモジュールだが、今回はこのモジュールの中の似たような関数を比較し、理解を深めていこうと思う。

目次

child_process.exec()

exec() は、Node.js から外部コマンドを実行し、その結果を取得できる。厳密にいうと、シェルを起動し、そのシェル上で指定のコマンドを実行している。

const childProcess = require('child_process');

childProcess.exec('cat package.json | wc -l', (error, stdout, stderr) => {
  if(error) return console.error('ERROR', error);
  console.log('STDOUT', stdout);  // string
  console.log('STDERR', stderr);  // string
});

シェルを起動しているので、パイプ | などが使えている。echo $SHELL で確かめてみると、macOS のターミナルでは /bin/bash が使われているようだった。

コールバック関数の stdoutstderr は、標準出力と標準エラー出力のバッファを返す。平たくいうと、指定したコマンドが完了するまで待ち、全ての結果をまとめて返してくれる。

パイプなどを含めてシェルコマンドが実行できてしまうので、シェル・インジェクションのセキュリティ問題を引き起こさないよう注意。シェルを起動しない関数で代替できないか検討しよう。

child_process.execSync()

exec() はコールバック関数の形になっているので、Node.js の util.promisify を使えば Promise 化して使える。

一方、同期関数である execSync() も用意してある。あまり推奨されないが、一応紹介。戻り値は Buffer 型なので toString() などかませる。

try {
  const stdout = childProcess.execSync('cat package.json | wc -l');
  console.log('STDOUT', stdout.toString());  // Buffer
}
catch(error) {
  console.log('STDERR', error.stderr.toString());  // Buffer
}

child_process.execFile()

exec() とよく似ているが、コチラはデフォルトではシェルを起動せず指定のコマンドを実行する。つまり、パイプなどが使えない。

シェルを介さず実行可能ファイルを直接実行するので、若干効率的。第1引数はコマンド名、すなわち実行可能ファイルの名前のみを指定し、オプション引数などを指定する場合は第2引数に配列で指定していく。

childProcess.execFile('node', ['--version'], (error, stdout, stderr) => {
  if(error) return console.error('ERROR', error);
  console.log('STDOUT', stdout);  // string
  console.log('STDERR', stderr);  // string
});

コチラも結果はバッファ受け取り。コマンドが全て終わるまで待たされる。

デフォルトではシェルを起動しないのだが、オプションを設定するとシェルを起動しその上でコマンド実行できる。つまり exec() と同じことができるようになる。

childProcess.execFile('cat package.json | wc -l && echo $SHELL', { shell: true }, (error, stdout, stderr) => {
  if(error) return console.error('ERROR', error);
  console.log('STDOUT', stdout);
  console.log('STDERR', stderr);
});

このように、{ shell: true } と指定すると、macOS で試した限りは /bin/bash が起動し、パイプを含めたコマンドが実行できる。

childProcess.execFile('cat package.json |', ['wc', '-l', '&&', 'echo $SHELL'], { shell: true }, (error, stdout, stderr) => {
  // 以下略…

こんな風に、第2引数に配列で値を取るやり方も上手く動く。コードとしてはメチャクチャで、わざわざこんな組み方はしないだろうが、コレでも動くワケだ。

child_process.execFileSync()

execFile()util.promisify で Promise 化できる。同期処理版として execFileSync() という関数もある。execSync() と同様、stdout を戻り値で受け取れ、stderr が発生する場合は catch することで受け取れる。

try {
  // shell: true も指定できる
  const stdout = childProcess.execFileSync('cat', ['package.json | wc -l'], { shell: true });
  console.log('STDOUT', stdout.toString());  // Buffer
}
catch(error) {
  console.log('STDERR', error.stderr.toString());  // Buffer
}

shell: true を指定すれば、exec()execSync() と変わらない、というのが execFile()execFileSync() だ。

child_process.spawn()

spawn()execFile() とよく似ている。デフォルトではシェルを起動せず、実行可能ファイルを別プロセスで起動する。一番の違いは、結果を Buffer ではなく Stream で受け取るところ。だからコールバック関数の形ではなく、.on() で指定するイベント形式なのだ。

const spawn = childProcess.spawn('cat', ['package.json']);

spawn.stdout.on('data', (data) => {
  console.log('STDOUT', data.toString());  // Stream
});
spawn.stderr.on('data', (data) => {
  console.log('STDERR', data.toString());  // Stream
});
spawn.on('close', (code) => {
  console.log('CODE', code);
});

spawn() も、execFile() 同様に { shell: true } のオプションを受け取れるので、シェル上でコマンドを実行することもできる。

const spawn = childProcess.spawn('cat package.json | wc -l && echo $SHELL', { shell: true });

spawn.stdout.on('data', (data) => {
  console.log('STDOUT', data.toString());
});
spawn.stderr.on('data', (data) => {
  console.log('STDERR', data.toString());
});
spawn.on('close', (code) => {
  console.log('CODE', code);
});

Buffer ではなく Stream で受け取る、と表現したが、上のように複数コマンドが動いていて、全ての結果を同時に受け取れない場合や、例えば docker build のように、実行に時間がかかり、順次標準出力が出てくるようなコマンドの場合は、その度に stdout.on('data') イベントが発動する。つまり、上のコードの実行結果は以下のようになるのだ。

$ node example-spawn.js
STDOUT  12
STDOUT  /bin/bash

12 を出力したのが wc -l で、その後の /bin/bash&& で繋いだ別コマンド。実行結果が出力されるタイミングが違うので、2回 console.log('STDOUT') が発動しているのだ。

このような挙動は、コマンドの出力結果が大量にある時は扱いやすい。

child_process.spawnSync()

せっかく Stream で順次受け取れる spawn() だが、spawnSync() という同期版もある。実行結果は Buffer で取得できるので、execFileSync() とほぼ同じ。ただ、何か問題があった場合も spawnSync() の実行部分で例外が発生しないのが特徴。

// shell: true オプションも使える
const spawn = childProcess.spawnSync('cat package.json | wc -l && echo $SHELL', { shell: true });
console.log('STDOUT', spawn.stdout.toString());  // Buffer
console.log('STDERR', spawn.stderr.toString());  // Buffer

child_process.fork()

fork() はコレまでのモノとちょっと毛色が違う。

何ができるかというと、引数で指定した JS ファイルを、別の node プロセスで起動・実行できる。

引数は require() と同じノリで、JS ファイルへのパスを取る。しかし実行される node プロセスは呼び出し元とは別プロセスになる。

親子のプロセス間でデータをやり取りするには、IPC 通信という仕組みを使う。

const childProcess = require('child_process');

const myChild = childProcess.fork('./my-child');

myChild.on('message', (message) => {
  console.log('子プロセスからメッセージを受信', message);
});

// 子プロセスにメッセージを送信
myChild.send('message', 'From Parent');
process.on('message', (message) => {
  console.log('親プロセスからメッセージを受信', message);
});

// 親プロセスにメッセージを送信
process.send('From Child');

一定の処理が終わったら、my-child.js の子プロセスを終了させたいが、この終了のさせ方は色々あったので、別の記事で検証結果をまとめることにする。

特徴比較表

それぞれの関数を紹介したので、特徴を比較してみる。

関数名 処理 シェル 戻り値の型
exec 非同期 起動する String (Buffer)
execSync 同期 起動する Buffer
execFile 非同期 起動しない String (Buffer)
execFile + shell:true 非同期 起動する String (Buffer)
execFileSync 同期 起動しない Buffer
execFileSync + shell:true 同期 起動する Buffer
spawn 非同期 (イベント) 起動しない String (Stream)
spawn + shell:true 非同期 (イベント) 起動する String (Stream)
spawnSync 同期 起動しない Buffer
spawnSync + shell:true 同期 起動する Buffer
fork 非同期的 (IPC) 起動しない -

こうしてみると、やはり fork() だけが「別プロセスを起動する」といっても、ちょっと毛色が違うことは分かるかと思う。だからコレだけはちょっと除外して、残りを見てみる。

exec()execSync() は、必ずシェルを起動する。それ以外の仕様は同じなので、execFile()execFileSync() のエイリアスみたいな存在だ。

execFile()execFileSync() と、spawn()spawnSync() は、戻り値の受け取り方が Buffer で一度にまとめてもらえるか、Stream で順次もらえるか、という違いが主だ。それ以外は { shell: true } オプションを渡すことでシェル起動の要否を切り替えられるし、あまり違いはないように見える。

こうやって整理できれば、「シェルを起動したいのか」「実行結果は逐次欲しいのかどうか」で判断して、使うべき関数を導けそうだ。

ついでにコードリーディングしてみる

ココまで来たら、内部実装を見てみよう。

child_process モジュールの内容は、Node.js リポジトリの ./lib/child_process.js./lib/internal/child_process.js の2つがメインとなって成り立っている。以下、関数ごとに該当する行をハイライトしたリンクを記載しておく。

ということで、spawn()spawnSync() が、一番ローレイヤーな大元の関数で、exec()execFile()spawn() のラッパー関数だということが分かった。どうりで動きがよく似ているワケだ。

fork() についても spawn() を利用していて、IPC 接続用の調整をしているくらいで、根っこは同じだというところは面白い。

どの関数を使うべきか、見極め方

ということで、Child Process の似たような関数群の違いが分かったと思う。最後にまとめとして、どういう場合にどの関数を使うべきか、見極め方を記して終わりにする。

  1. 基本的に同期関数は使わないことを考えると、execSync()execFileSync()spawnSync() の存在は無視していい
  2. Node.js スクリプトを別プロセスで起動して、親子プロセス間でやり取りしたければ fork() 一択。
    そうでなければ次の判断基準を見る
  3. コマンドの実行結果の量が少なめで、一度に受け取りたければ execFile()
    コマンドの実行結果を順次取得したいとか、量が多くバッファを超えそうな場合は spawn() を選ぶ
  4. 実行可能ファイルの呼び出しだけでなく、パイプなどシェルの機能を使いたければ、そこにさらに { shell: true } オプションを渡す
    • execFile() + { shell: true } の場合は exec() がほぼ等価なエイリアスとして使える (混乱するようなら exec() は存在を無視していい)
    • (シェルを使う場合はシェル・インジェクションに注意すること)

エイリアス的な関数がいくつかあるのでちょっとこんがらがるが、整理してみたらよく分かった。