Node.js child_process模組解讀

Randal發表於2018-06-01

在介紹child_process模組之前,先來看一個例子。

const http = require('http');
const longComputation = () => {
  let sum = 0;
  for (let i = 0; i < 1e10; i++) {
    sum += i;
  };
  return sum;
};
const server = http.createServer();
server.on('request', (req, res) => {
  if (req.url === '/compute') {
    const sum = longComputation();
    return res.end(`Sum is ${sum}`);
  } else {
    res.end('Ok')
  }
});

server.listen(3000);
複製程式碼

可以試一下使用上面的程式碼啟動Node.js服務,然後開啟兩個瀏覽器選項卡分別訪問/compute和/,可以發現node服務接收到/compute請求時會進行大量的數值計算,導致無法響應其他的請求(/)。

在Java語言中可以通過多執行緒的方式來解決上述的問題,但是Node.js在程式碼執行的時候是單執行緒的,那麼Node.js應該如何解決上面的問題呢?其實Node.js可以建立一個子程式執行密集的cpu計算任務(例如上面例子中的longComputation)來解決問題,而child_process模組正是用來建立子程式的。

建立子程式的方式

child_process提供了幾種建立子程式的方式

  • 非同步方式:spawn、exec、execFile、fork
  • 同步方式:spawnSync、execSync、execFileSync

首先介紹一下spawn方法

child_process.spawn(command[, args][, options])

command: 要執行的指令
args:    傳遞引數
options: 配置項
複製程式碼
const { spawn } = require('child_process');
const child = spawn('pwd');
複製程式碼

pwd是shell的命令,用於獲取當前的目錄,上面的程式碼執行完控制檯並沒有任何的資訊輸出,這是為什麼呢?

控制檯之所以不能看到輸出資訊的原因是由於子程式有自己的stdio流(stdin、stdout、stderr),控制檯的輸出是與當前程式的stdio繫結的,因此如果希望看到輸出資訊,可以通過在子程式的stdout 與當前程式的stdout之間建立管道實現

child.stdout.pipe(process.stdout);
複製程式碼

也可以監聽事件的方式(子程式的stdio流都是實現了EventEmitter API的,所以可以新增事件監聽)

child.stdout.on('data', function(data) {
  process.stdout.write(data);
});
複製程式碼

在Node.js程式碼裡使用的console.log其實底層依賴的就是process.stdout

除了建立管道之外,還可以通過子程式和當前程式共用stdio的方式來實現

const { spawn } = require('child_process');
const child = spawn('pwd', {
  stdio: 'inherit'
});
複製程式碼

stdio選項用於配置父程式和子程式之間建立的管道,由於stdio管道有三個(stdin, stdout, stderr)因此stdio的三個可能的值其實是陣列的一種簡寫

  • pipe 相當於['pipe', 'pipe', 'pipe'](預設值)
  • ignore 相當於['ignore', 'ignore', 'ignore']
  • inherit 相當於[process.stdin, process.stdout, process.stderr]

由於inherit方式使得子程式直接使用父程式的stdio,因此可以看到輸出

ignore用於忽略子程式的輸出(將/dev/null指定為子程式的檔案描述符了),因此當ignore時child.stdout是null。

spawn預設情況下並不會建立子shell來執行命令,因此下面的程式碼會報錯

const { spawn } = require('child_process');
const child = spawn('ls -l');
child.stdout.pipe(process.stdout);

// 報錯
events.js:167
      throw er; // Unhandled 'error' event
      ^

Error: spawn ls -l ENOENT
    at Process.ChildProcess._handle.onexit (internal/child_process.js:229:19)
    at onErrorNT (internal/child_process.js:406:16)
    at process._tickCallback (internal/process/next_tick.js:63:19)
    at Function.Module.runMain (internal/modules/cjs/loader.js:746:11)
    at startup (internal/bootstrap/node.js:238:19)
    at bootstrapNodeJSCore (internal/bootstrap/node.js:572:3)
Emitted 'error' event at:
    at Process.ChildProcess._handle.onexit (internal/child_process.js:235:12)
    at onErrorNT (internal/child_process.js:406:16)
    [... lines matching original stack trace ...]
    at bootstrapNodeJSCore (internal/bootstrap/node.js:572:3)
複製程式碼

如果需要傳遞引數的話,應該採用陣列的方式傳入

const { spawn } = require('child_process');
const child = spawn('ls', ['-l']);
child.stdout.pipe(process.stdout);
複製程式碼

如果要執行ls -l | wc -l命令的話可以採用建立兩個spawn命令的方式

const { spawn } = require('child_process');
const child = spawn('ls', ['-l']);
const child2 = spawn('wc', ['-l']);
child.stdout.pipe(child2.stdin);
child2.stdout.pipe(process.stdout);
複製程式碼

也可以使用exec

const { exec } = require('child_process');
exec('ls -l | wc -l', function(err, stdout, stderr) {
  console.log(stdout);
});
複製程式碼

由於exec會建立子shell,所以可以直接執行shell管道命令。spawn採用流的方式來輸出命令的執行結果,而exec也是將命令的執行結果快取起來統一放在回撥函式的引數裡面,因此exec只適用於命令執行結果資料小的情況。

其實spawn也可以通過配置shell option的方式來建立子shell進而支援管道命令,如下所示

const { spawn, execFile } = require('child_process');
const child = spawn('ls -l | wc -l', {
  shell: true
});
child.stdout.pipe(process.stdout);
複製程式碼

配置項除了stdio、shell之外還有cwd、env、detached等常用的選項

cwd用於修改命令的執行目錄

const { spawn, execFile, fork } = require('child_process');
const child = spawn('ls -l | wc -l', {
  shell: true,
  cwd: '/usr'
});
child.stdout.pipe(process.stdout);
複製程式碼

env用於指定子程式的環境變數(如果不指定的話,預設獲取當前程式的環境變數)

const { spawn, execFile, fork } = require('child_process');
const child = spawn('echo $NODE_ENV', {
  shell: true,
  cwd: '/usr'
});
child.stdout.pipe(process.stdout);
NODE_ENV=randal node b.js

// 輸出結果
randal
複製程式碼

如果指定env的話就會覆蓋掉預設的環境變數,如下

const { spawn, execFile, fork } = require('child_process');
spawn('echo $NODE_TEST $NODE_ENV', {
  shell: true,
  stdio: 'inherit',
  cwd: '/usr',
  env: {
    NODE_TEST: 'randal-env'
  }
});

NODE_ENV=randal node b.js

// 輸出結果
randal-env
複製程式碼

detached用於將子程式與父程式斷開連線

例如假設存在一個長時間執行的子程式

// timer.js
while(true) {

}
複製程式碼

但是主程式並不需要長時間執行的話就可以用detached來斷開二者之間的連線

const { spawn, execFile, fork } = require('child_process');
const child = spawn('node', ['timer.js'], {
  detached: true,
  stdio: 'ignore'
});
child.unref();
複製程式碼

當呼叫子程式的unref方法時,同時配置子程式的stdio為ignore時,父程式就可以獨立退出了

execFile與exec不同,execFile通常用於執行檔案,而且並不會建立子shell環境

fork方法是spawn方法的一個特例,fork用於執行js檔案建立Node.js子程式。而且fork方式建立的子程式與父程式之間建立了IPC通訊管道,因此子程式和父程式之間可以通過send的方式傳送訊息。

注意:fork方式建立的子程式與父程式是完全獨立的,它擁有單獨的記憶體,單獨的V8例項,因此並不推薦建立很多的Node.js子程式

fork方式的父子程式之間的通訊參照下面的例子

parent.js

const { fork } = require('child_process');

const forked = fork('child.js');

forked.on('message', (msg) => {
  console.log('Message from child', msg);
});

forked.send({ hello: 'world' });

複製程式碼

child.js

process.on('message', (msg) => {
  console.log('Message from parent:', msg);
});

let counter = 0;

setInterval(() => {
  process.send({ counter: counter++ });
}, 1000);
複製程式碼
node parent.js

// 輸出結果
Message from parent: { hello: 'world' }
Message from child { counter: 0 }
Message from child { counter: 1 }
Message from child { counter: 2 }
Message from child { counter: 3 }
Message from child { counter: 4 }
Message from child { counter: 5 }
Message from child { counter: 6 }
複製程式碼

回到本文初的那個問題,我們就可以將密集計算的邏輯放到單獨的js檔案中,然後再通過fork的方式來計算,等計算完成時再通知主程式計算結果,這樣避免主程式繁忙的情況了。

compute.js

const longComputation = () => {
  let sum = 0;
  for (let i = 0; i < 1e10; i++) {
    sum += i;
  };
  return sum;
};

process.on('message', (msg) => {
  const sum = longComputation();
  process.send(sum);
});


複製程式碼

index.js

const http = require('http');
const { fork } = require('child_process');

const server = http.createServer();

server.on('request', (req, res) => {
  if (req.url === '/compute') {
    const compute = fork('compute.js');
    compute.send('start');
    compute.on('message', sum => {
      res.end(`Sum is ${sum}`);
    });
  } else {
    res.end('Ok')
  }
});

server.listen(3000);
複製程式碼

監聽程式事件

通過前述幾種方式建立的子程式都實現了EventEmitter,因此可以針對程式進行事件監聽

常用的事件包括幾種:close、exit、error、message

close事件當子程式的stdio流關閉的時候才會觸發,並不是子程式exit的時候close事件就一定會觸發,因為多個子程式可以共用相同的stdio。

close與exit事件的回撥函式有兩個引數code和signal,code程式碼子程式最終的退出碼,如果子程式是由於接收到signal訊號終止的話,signal會記錄子程式接受的signal值。

先看一個正常退出的例子

const { spawn, exec, execFile, fork } = require('child_process');
const child = exec('ls -l', {
  timeout: 300
});
child.on('exit', function(code, signal) {
  console.log(code);
  console.log(signal);
});

// 輸出結果
0
null
複製程式碼

再看一個因為接收到signal而終止的例子,應用之前的timer檔案,使用exec執行的時候並指定timeout

const { spawn, exec, execFile, fork } = require('child_process');
const child = exec('node timer.js', {
  timeout: 300
});
child.on('exit', function(code, signal) {
  console.log(code);
  console.log(signal);
});
// 輸出結果
null
SIGTERM
複製程式碼

注意:由於timeout超時的時候error事件並不會觸發,並且當error事件觸發時exit事件並不一定會被觸發

error事件的觸發條件有以下幾種:

  • 無法建立程式
  • 無法結束程式
  • 給程式傳送訊息失敗

注意當程式碼執行出錯的時候,error事件並不會觸發,exit事件會觸發,code為非0的異常退出碼

const { spawn, exec, execFile, fork } = require('child_process');
const child = exec('ls -l /usrs');
child.on('error', function(code, signal) {
  console.log(code);
  console.log(signal);
});
child.on('exit', function(code, signal) {
  console.log('exit');
  console.log(code);
  console.log(signal);
});

// 輸出結果
exit
1
null
複製程式碼

message事件適用於父子程式之間建立IPC通訊管道的時候的資訊傳遞,傳遞的過程中會經歷序列化與反序列化的步驟,因此最終接收到的並不一定與傳送的資料相一致。

sub.js

process.send({ foo: 'bar', baz: NaN });
複製程式碼
const cp = require('child_process');
const n = cp.fork(`${__dirname}/sub.js`);

n.on('message', (m) => {
  console.log('got message:', m);   // got message: { foo: 'bar', baz: null }
});
複製程式碼

關於message有一種特殊情況要注意,下面的message並不會被子程式接收到

const { fork } = require('child_process');

const forked = fork('child.js');

forked.send({
  cmd: "NODE_foo",
  hello: 'world'
});
複製程式碼

當傳送的訊息裡面包含cmd屬性,並且屬性的值是以NODE_開頭的話,這樣的訊息是提供給Node.js本身保留使用的,因此並不會發出message事件,而是會發出internalMessage事件,開發者應該避免這種型別的訊息,並且應當避免監聽internalMessage事件。

message除了傳送字串、object之外還支援傳送server物件和socket物件,正因為支援socket物件才可以做到多個Node.js程式監聽相同的埠號。

未完待續......

參考資料

medium.freecodecamp.org/node-js-chi…

nodejs.org/dist/latest…

相關文章