Node.js の Child Process 研究 : fork の使い方、子プロセスの切り方を検証

Node.js の組み込みモジュール child_process にある、fork() 関数。require() で他の JS ファイルを読み込むのと違って、指定の JS ファイルを別の node プロセスで起動できるのがこの関数の特徴だ。

今回は、この関数の特徴、使いどころ、子プロセスの終了の仕方をまとめる。

目次

fork() の基本的な使い方

child_process.fork() は、require() に近い書式で JS ファイルを指定し、その JS ファイルを呼び出し元とは別の node プロセスで実行する。Node.js は基本的にシングルプロセス・シングルスレッドで動作するので、このように別プロセスに処理を分ければ、マルチプロセスで処理ができるというワケ。

そして、呼び出し元である親プロセスと、呼び出された子プロセスとは、IPC (Inter Process Communication) 通信によって情報をやり取りできる。親プロセスから子プロセスにパラメータを渡し、子プロセスから親プロセスへ演算結果を返す、といったことが可能だ。

基本的なコーディングの方法は以下のとおり。

const childProcess = require('child_process');

// 本ファイルと同ディレクトリにある my-child.js のプロセスを生成する
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');

まず親プロセスのファイルから、子プロセスを fork() で生成する。fork() 関数の戻り値は ChildProcess クラスのインスタンスで、EventEmitter のようにイベントが設定できる。on('message') でメッセージ受信時の処理を指定し、send() でメッセージを送る。送る内容は裏で文字列化されるようだが、オブジェクトなどをそのまま指定して送ったりもできる。

一方、子プロセスの方は、process.on('message') で親プロセスからメッセージを受信した時の処理を書いておき、process.send() を使って親プロセスにメッセージを送る。

fork() の使いどころ

child_process.fork() を使えば、呼び出し元とは別の node プロセスで実行できるワケだが、どういう時に使うと良いか。

例えば、HTTP 通信や Promise を使った非同期処理などは、シングルスレッドで動作する Node.js の中でイベントループという仕組みによって制御されている。これらの処理によって他の処理が長時間ブロックされることはないワケで、であれば別プロセスに切り出す必要性はあまりない。

じゃあ別プロセスに切り出した方が良い処理とは何かというと、CPU バウンド (CPU の処理能力を利用する) な同期処理を回避させる際に使うと良いだろう。

例えば、JSON.stringify()JSON.parse() などは、CPU とメモリに負荷のかかる、同期処理だ。通常の用途であれば気にならないが、大容量のテキストファイルから JSON.parse() したりしようとすると、処理が完了するまではこの関数がスレッドを専有し、他の処理がブロックされて動かなくなってしまう。

コレを回避するには、CPU バウンドな処理を別のプロセスで実行し、実行結果を非同期に取得するやり方を取る。

const childProcess = require('child_process');

// 重い処理を実行させる子プロセスを生成する
const myChild = childProcess.fork('./my-child');

myChild.on('message', (jsonObj) => {
  console.log('子プロセスから結果を受信', jsonObj);
});

// 子プロセスに処理対象のファイルパスを送ることにする
myChild.send('message', { filePath: './large-file.json' });
const fs = require('fs');

process.on('message', (message) => {
  console.log('このファイルを JSON パースして返す', message.filePath);
  const file = fs.readFileSync(message.filePath, 'utf-8');
  
  // 以下の同期処理に時間がかかる
  const jsonObj = JSON.parse(file);
  
  // 親プロセスに結果を送信する
  process.send(jsonObj);
});

…こんな感じで、JSON.parse() 実行中も親プロセスでは他の処理ができるようになり、パース結果は子プロセスの処理を待って非同期に受け取るというワケだ。

処理が終わった子プロセスを終了させたい

さて、このような実装をした時に、子プロセスは自然に終了してくれない。実装した人間の感覚からすると、「もう JSON.parse() は終わったし、親プロセスに結果を返したから、子プロセスはお役御免だよ」と思うのだが、process.on() で設定したイベントを解除していないので、「まだイベントが飛んでくるかも」と待機した状態になるのだ。

つまり、親子間で通信が終わったことを示すための実装を追加しないと、子プロセスは生成されたまま残り続けてしまうワケだ。

コレを解消する方法はいくつかあるので、それぞれ調べた結果を紹介する。

process.once() で1回だけイベント設定する

コレまで、子プロセスでは process.on() を使ってイベント設定していたが、コレを process.once() に変える、という方法。

process.on() を使うと、同じイベントが何度も飛んでくるかもしれない、と待機を続ける動きになるが、process.once() にした場合は、1回イベントを受信したら、イベント登録を解除する。そのため、子プロセスでやることがなくなり、子プロセスが終了するというワケだ。

先程のサンプルコードでいうと、子プロセス側の実装をこのように直すだけ。

//      ↓ ココを on() ではなく once() にするだけ
process.once('message', (message) => {
  // 何か時間のかかる処理…
  
  // 親プロセスに結果を送信する
  process.send('From Child');
});

このやり方は、親プロセス側、子プロセス側ともに、明示的にプロセスを終了させるコードが出てこないことになるので、シンプルかもしれない。

ただ、子プロセスで設定したイベントが1回も呼ばれない場合は、そのプロセスが残り続けるので、呼び忘れに注意。

子プロセスで process.removeAllListeners() を実行する

次は、子プロセス側から明示的にプロセスの終了を呼ぶ方法。

子プロセスでは process.on()process.send() など、process というグローバル変数を使うが、この変数は node プロセスが異なるので、親プロセス側のコードで使っている process とは別物になる。

そこで、子プロセス内に定義されている全てのリスナを解除することで、子プロセスがやることをなくし、子プロセスを終了させる。

process.on('message', (message) => {
  // 何か時間のかかる処理…
  
  // 親プロセスに結果を送信する
  process.send('From Child');
  
  // 本プロセスのリスナを全解除し終了する
  process.removeAllListeners();
});

// 以下のように、他にイベント処理があると removeAllListeners() では上手くプロセス終了できない
// setInterval(() => console.log('インターバル処理'), 1000);

子プロセス内で setInterval() などを設定していると、process.removeAllListeners() では上手く終了させられなかったりする。実装に合わせて活用しよう。

当然ながら、親プロセス側で process.removeAllListeners() を呼んでも意味がないので注意。

親プロセスで ChildProcess.disconnect() を実行する

3つ目は、親プロセスから ChildProcess.disconnect() を呼んでやる方法。この関数は IPC 接続を切断するモノなので、IPC 切断によりやることがなくなった子プロセスが終了する、というワケ。

const childProcess = require('child_process');

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

myChild.on('message', (jsonObj) => {
  console.log('子プロセスから結果を受信', jsonObj);
  
  // 子プロセスとの通信を切断する
  myChild.disconnect();
});

// 切断・終了関連のイベントを定義しておく
myChild.on('disconnect', () => { console.log('IPC 接続を終了'  ); });
myChild.on('exit'      , () => { console.log('プロセス終了'    ); });
myChild.on('close'     , () => { console.log('標準入出力を終了'); });

// 子プロセスに処理を開始させる
myChild.send('message', { filePath: './large-file.json' });

子プロセス側は process.removeAllListeners() などは不要。

このように実装すると、子プロセスからメッセージを受け取った後、myChild.on('disconnect')myChild.on('exit') が順に実行され、子プロジェクトが終了する。標準入出力を閉じる myChild.on('close') は実行されないようだった。

親プロセスで ChildProcess.kill() を実行する

最後4つ目は、親プロセスから ChildProcess.kill() を呼ぶモノ。disconnect() に似ているが、コチラは SIGTERM シグナルが送信されるので、一番確実にプロセス終了させられると思われる。

const childProcess = require('child_process');

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

myChild.on('message', (jsonObj) => {
  console.log('子プロセスから結果を受信', jsonObj);
  
  // 子プロセスを Kill する
  myChild.kill();
});

// 切断・終了関連のイベントを定義しておく
myChild.on('disconnect', () => { console.log('IPC 接続を終了'  ); });
myChild.on('exit'      , () => { console.log('プロセス終了'    ); });
myChild.on('close'     , () => { console.log('標準入出力を終了'); });

// 子プロセスに処理を開始させる
myChild.send('message', { filePath: './large-file.json' });

親プロセス側をこのように実装すると、myChild.on('disconnect')myChild.on('exit')myChild.on('close') が順に実行され、子プロジェクトが終了する。

その他

その他、ChildProcess インスタンスが持つ ChildProcess.pid を使用し、

child_process.spawn('kill', [myChild.pid]);

と実行して削除する方法もあった。

コレはガチで OS の kill コマンドを呼んでいるので、ちょっとやりすぎかなぁという気もする。

結局どのやり方で子プロセスを終了させるべきか?

いくつか方法があって、いずれも上手く動くことは確認したが、どのやり方を採用すべきか。僕の考え方を書いておく。

  1. 子プロセスが1回の fork() につき1回だけ処理することが確実なら、process.once() でイベント登録することで終了させる。プロセス終了のための実装は要らない
  2. 大抵は子プロセスから親プロセスに対して演算結果を返したいだろうから、ChildProcess.kill() で指定の子プロセスを Kill するのが確実で良さそう
    • ChildProcess.disconnect() はあまり使わないかも
  3. 親プロセスが子プロセスを呼び出した後、子プロセスからの応答が必要ないような処理であれば、子プロセス内で処理を終え、process.removeAllListeners() で終了すると、親プロセスは子の終了タイミングを見極めなくて良くなる

…と、こんな感じだろうか。

child_process.fork() を賢く使って、CPU バウンドな処理によるブロッキングを回避しよう。