你可能想知道的 Node 子進程模塊

本文首發於個人博客,轉載請註明出處:http://kohpoll.github.io/blog/2016/04/25/about-the-node-child-process/javascript

最近在使用 Node 的子進程模塊實現一些功能,對相關知識進行了一個系統的學習總結,這篇文章將會簡要介紹我總結的 Node 中和進程有關的內容。包括:進程和線程、Node 的單線程的真正含義、建立進程的三種方法、進程間通訊、進程以及信號量。有不當之處歡迎提出,一塊兒交流。html

進程及線程

在真正開始介紹 Node 中的 child_process 模塊以前,先來簡要介紹一些操做系統的基礎知識。java

咱們首先從操做系統的任務調度開始。node

現代的操做系統通常都是「多任務」的,能夠同時運行多個任務。好比:咱們能夠一邊聽歌一邊敲代碼一邊下載小電影,還有一些任務在後臺悄悄同時運行。可是當咱們只有一個 CPU 時,操做系統又是怎麼作到「多任務」的?linux

操做系統會進行調度(任務切換)來實現多任務:也就是一個任務執行一段時間後被暫停,下一個任務再執行一段時間,而後不斷循環執行下去,這樣每一個任務都能獲得交替執行。雖然是交替執行,可是CPU 執行效率很高,在各個任務間快速切換,給咱們的感受就是多個任務在「同時運行」,也就是咱們說的「多任務」。git

上面的調度並非真正的並行執行,真正的並行執行多個任務實際上只能在多核 CPU 上實現。可是,因爲任務數量確定會遠遠多於 CPU 的核心數量,操做系統也會自動把不少任務輪流調度到每一個 CPU 上執行。github

一個任務實際上就是一個進程(Process),它是操做系統進行資源分配和調度的最小單位,是應用程序運行的載體,有本身獨立的內存空間。shell

可是有些進程並不知足同時幹一件事,好比:播放器播放小電影的時候,它能夠同時播放視頻、音頻。編程

在一個進程內要同時幹多件事就須要運行多個「子任務」,這些進程內的子任務就是線程(Thread),它是程序執行的最小單位,一個進程能夠有一個或多個線程,各個線程間能夠共享進程的內存空間。windows

因爲每一個進程至少要幹一件事,因此,一個進程至少有一個線程。固然,進程能夠有多個線程,多個線程能夠「同時執行」,多線程的執行方式和多進程是同樣的,也是由操做系統在多個線程之間快速切換,讓每一個線程都短暫地交替運行,看起來就像同時執行同樣。可是,線程間的上下文切換要比進程的上下文切換開銷小,也快得多。

咱們能夠經過資源管理器(windows)或者活動監視器(mac)來查看咱們系統裏的進程和線程,以下圖是活動監視器的截圖:

活動監視器中的進程及線程

固然也能夠經過 ps、top 等命令來查看進程信息,能夠參考:http://www.imooc.com/article/1071

讓咱們總結下:

  • 線程是程序執行的最小單元,進程是任務調度的最小單元

  • 一個進程由一個或多個線程組成(至少一個),線程間能夠共享進程的內存空間,進程間互相獨立(有各自的內存空間)

  • 操做系統使用 CPU 時間分片來調度進程、線程的執行,從而實現多任務

  • 線程間的切換比進程間切換開銷小

關於 Node 的單線程

咱們知道 Node 相似於瀏覽器裏面的 JavaScript,是單線程的。那咱們如今須要理解 Node 的單線程究竟是什麼意思?

這裏說的單線程是指咱們所編寫的代碼運行在單線程上,實際上 Node 並非真的「單線程」。

當咱們執行 node app.js 時啓動了一個進程,可是這個進程並非只有一個線程,而是同時建立了不少個線程(好比:異步 IO 須要的一些 IO 線程)。以下圖所示(編號爲 92347 的進程一共有 5 個線程):

Node 的進程和線程

可是,仍然只有一個線程會運行咱們編寫的代碼。這就是 Node 單線程的含義。

Node 實際上從語言層面就不支持建立線程,咱們只有能力建立進程。這讓咱們的程序狀態單一,不用在乎狀態同步、死鎖、上下文切換開銷等等多線程編程中的頭疼問題。固然,咱們能夠經過進程間的通訊來共享一些「狀態」,但並非線程間共享的那種狀態。

單線程也會帶來一些問題:

  1. 沒法利用多核 CPU(只能得到一個 CPU 的時間分片)

  2. 錯誤會引發整個應用退出(整個應用就一個進程,掛了就掛了)

  3. 大量計算長時間佔用 CPU,致使阻塞線程內其餘操做(異步 IO 發不出調用,已完成的異步 IO 回調不能及時執行)

這些問題實際上都有對應的解決方案。咱們會使用 Master-Worker 的管理方式來建立和管理多個工做進程(工做進程數量通常會等於系統 CPU 的核心數量),保證應用可以充分利用多核 CPU,同時在發生錯誤時能夠優雅退出和自動重啓(好比 recluster 模塊)。咱們會新建立一個獨立進程來進行耗時的計算,而後將計算結果傳回給主線程。它們本質上都在使用 Node 提供的子進程功能。

進程建立簡明指南

在 Node 中,大致上有三種建立進程的方法:

  • exec / execFile

  • spawn

  • fork

exec / execFile

exec(command, options, callback)execFile(file, args, options, callback) 比較相似,會使用一個 Buffer 來存儲進程執行後的標準輸出結果,們能夠一次性在 callback 裏面獲取到。不太適合輸出數據量大的場景。

須要注意的是,exec 會首先建立一個新的 shell 進程出來,而後執行 commandexecFile 則是直接將可執行的 file 建立爲新進程執行。因此,execfile 會比 exec 高效一些。

exec 比較適合用來執行 shell 命令,而後獲取輸出(好比:exec('ps aux | grep "node"')),可是 execFile 卻沒辦法這麼用,由於它實際上只接受一個可執行的命令,而後執行(無法使用 shell 裏面的管道之類的東西)。

// child.js
console.log('child argv: ', process.argv);
// parent.js
const child_process = require('child_process');
const p = child_process.exec(
  'node child.js a b', // 執行的命令
  {},
  (err, stdout, stderr) => {
    if (err) {
      // err.code 是進程退出時的 exit code,非 0 都被認爲錯誤
      // err.signal 是結束進程時發送給它的信號值
      console.log('err:', err, err.code, err.signal);
    }
    console.log('stdout:', stdout);
    console.log('stderr:', stderr);
  }
);
console.log('child pid:', p.pid);
// parent.js
const p = child_process.execFile(
  'node', // 可執行文件
  ['child.js', 'a', 'b'], // 傳遞給命令的參數
  {},
  (err, stdout, stderr) => {
    if (err) {
      // err.code 是進程退出時的 exit code,非 0 都被認爲錯誤
      // err.signal 是結束進程時發送給它的信號值
      console.log('err:', err, err.code, err.signal);
    }
    console.log('stdout:', stdout);
    console.log('stderr:', stderr);
  }
);
console.log('child pid:', p.pid);

兩個方法還能夠傳遞一些配置項,以下所示:

{
    // 能夠指定命令在哪一個目錄執行
    'cwd': null,
    // 傳遞環境變量,node 腳本能夠經過 process.env 獲取到         
    'env': {},
    // 指定 stdout 輸出的編碼,默認用 utf8 編碼爲字符串(若是指定爲 buffer,那 callback 的 stdout 參數將會是 Buffer)       
    'encoding': 'utf8',
    // 指定執行命令的 shell,默認是 /bin/sh(unix) 或者 cmd.exe(windows)
    'shell': '',
    // kill 進程時發送的信號量
    'killSignal': 'SIGTERM',
    // 子進程超時未執行完,向其發送 killSignal 指定的值來 kill 掉進程
    'timeout': 0,
    // stdout、stderr 容許的最大輸出大小(以 byte 爲單位),若是超過了,子進程將被 kill 掉(發送 killSignal 值)
    'maxBuffer': 200 * 1024,
    // 指定用戶 id
    'uid': 0,
    // 指定組 id
    'gid': 0
}

spawn

spawn(command, args, options) 適合用在進程的輸入、輸出數據量比較大的狀況(由於它支持以 stream 的使用方式),能夠用於任何命令。

// child.js
console.log('child argv: ', process.argv);
process.stdin.pipe(process.stdout);
// parent.js
const p = child_process.spawn(
  'node', // 須要執行的命令
  ['child.js', 'a', 'b'], // 傳遞的參數
  {}
);
console.log('child pid:', p.pid);
p.on('exit', code => {
  console.log('exit:', code);
});

// 父進程的輸入直接 pipe 給子進程(子進程能夠經過 process.stdin 拿到)
process.stdin.pipe(p.stdin);

// 子進程的輸出 pipe 給父進程的輸出
p.stdout.pipe(process.stdout);
/* 或者經過監聽 data 事件來獲取結果
var all = '';
p.stdout.on('data', data => {
    all += data; 
});
p.stdout.on('close', code => {
    console.log('close:', code);
    console.log('data:', all);
});
*/

// 子進程的錯誤輸出 pipe 給父進程的錯誤輸出
p.stderr.pipe(process.stderr);

咱們能夠執行 cat bigdata.txt | node parent.js 來進行測試,bigdata.txt 文件的內容將被打印到終端。

spawn 方法的配置(options)以下:

{
    // 能夠指定命令在哪一個目錄執行
    'cwd': null,
    // 傳遞環境變量,node 腳本能夠經過 process.env 獲取到         
    'env': {},
    // 配置子進程的 IO
    'stdio': 'pipe',
    // 爲子進程獨立運行作好準備
    'detached': false,
    // 指定用戶 id
    'uid': 0,
    // 指定組 id
    'gid': 0
}

咱們這裏主要介紹下 detachedstdio 這兩個配置。

stdio

stdio 用來配置子進程和父進程之間的 IO 通道,能夠傳遞一個數組或者字符串。好比,['pipe', 'pipe', 'pipe'],分別配置:標準輸入、標準輸出、標準錯誤。若是傳遞字符串,則三者將被配置成同樣的值。咱們簡要介紹其中三個能夠取的值:

  • pipe(默認):父子進程間創建 pipe 通道,能夠經過 stream 的方式來操做 IO

  • inherit:子進程直接使用父進程的 IO

  • ignore:不創建 pipe 通道,不能 pipe、不能監聽 data 事件、IO 全被忽略

好比上面的代碼若是改寫成下面這樣,效果徹底同樣(子進程直接使用了父進程的 IO):

const p = child_process.spawn(
  'node', ['child.js', 'a', 'b'],
  {
    // 'stdio': ['inherit', 'inherit', 'inherit']
    'stdio': 'inherit'
  }
);
console.log('child pid:', p.pid);

p.on('exit', code => {
  console.log('exit:', code);
});

detached

detached 配置主要用來建立常駐的「後臺」進程,好比下面的代碼:

// child.js
setInterval(() => {
  console.log('child');
}, 1000);
// parent.js
const p = child_process.spawn(
  'node', ['child.js', 'a', 'b'],
  {
    'stdio': 'ignore', // 父子進程間不創建通道
    'detached': true   // 讓子進程能在父進程退出後繼續運行
  }
);
// 默認狀況,父進程會等子進程,這個方法可讓子進程徹底獨立運行
p.unref();

console.log('child pid:', p.pid);

p.on('exit', code => {
  console.log('exit:', code);
});

這樣就實現了常駐的後臺進程,父進程退出了、shell 關掉了,子進程都會一直運行,直到手動將它 kill 掉。

雖然在子進程裏面,咱們每隔 1s 就輸出了一個信息,可是其實根本就看不到。若是咱們想要記錄子進程的輸出的話,能夠給它指定一個單獨的 IO(不能和父進程創建 IO 通道,不然無法獨立運行):

const out = fs.openSync('./out.log', 'a');
const err = fs.openSync('./err.log', 'a');

// parent.js
const p = child_process.spawn(
  'node', ['child.js', 'a', 'b'],
  {
    'stdio': ['ignore', out, err], // 父子進程間不創建通道
    'detached': true   // 讓子進程能在父進程退出後繼續運行
  }
);
// 默認狀況,父進程會等子進程,這個方法可讓子進程徹底獨立運行
p.unref();

console.log('child pid:', p.pid);

p.on('exit', code => {
  console.log('exit:', code);
});

fork

fork(modulePath, args, options) 其實是 spawn 的一個「特例」,會建立一個新的 V8 實例,新建立的進程只能用來運行 Node 腳本,不能運行其餘命令。而且會在父子進程間創建 IPC 通道,從而實現進程間通訊。

// child.js
console.log('child argv: ', process.argv);
process.stdin.pipe(process.stdout);
// parent.js
const p = child_process.fork(
  'child.js', // 須要執行的腳本路徑
  ['a', 'b'], // 傳遞的參數
  {}
);
console.log('child pid:', p.pid);

p.on('exit', code => {
  console.log('exit:', code);
});

上面代碼的效果和使用 spawn 並配置 stdio: inherit 的效果是一致的。咱們看下該方法的配置(options)就知道緣由了:

{
    // 能夠指定命令在哪一個目錄執行
    'cwd': null,
    // 傳遞環境變量,node 腳本能夠經過 process.env 獲取到         
    'env': {},
    // 建立子進程使用的 node 的執行路徑(默認是:process.execPath)
    'execPath': '',
    // 建立子進程時,傳遞給執行程序的參數(默認是:process.execArgv)
    'execArgv': [],
    // 設置爲 true 時,父子間將創建 IO 的 pipe 通道(pipie);設置爲 false 時(默認),子進程直接使用父進程的 IO(inherit)
    'silent': false,
    // 指定用戶 id
    'uid': 0,
    // 指定組 id
    'gid': 0
}

總結

  • exec / execFile:使用 Buffer 來存儲進程的輸出,能夠在回調裏面獲取輸出結果,不太適合數據量大的狀況;能夠執行任何命令;不建立 V8 實例

  • spawn:支持 stream 方式操做輸入輸出,適合數據量大的狀況;能夠執行任何命令;不建立 V8 實例;能夠建立常駐的後臺進程

  • fork:spawn 的一個特例;只能執行 Node 腳本;會建立一個 V8 實例;會創建父子進程的 IPC 通道,可以進行通訊

進程間通訊

咱們上面介紹的三種建立子進程的方法都會返回一個 ChildProcess 類的實例,它其實繼承於 EventEmitter

咱們上面已經看到了一些用法:

  • 獲取進程的 pid

  • 監聽 exit 等事件(其餘事件有:errorclose 等)

  • 訪問 stdinstdoutstderr 屬性(這些屬性又是 Stream 的實例,能夠像操做 stream 同樣進行操做)

這部分咱們簡要介紹下進程間通訊的方法,主要就是經過收發消息來實現。

實際上默認狀況下,只有 fork 出的子進程才能和父進程收發消息,由於 fork 會創建父子進程的 IPC 通道,其餘方法並不會創建這種通道。

// child.js
console.log('child argv: ', process.argv);
process.on('message', m => {
  console.log('message in child:', m);
});
setTimeout(() => {
  process.send('send from child');
}, 2000);
// parent.js
const p = child_process.fork(
  'child.js', ['a', 'b'],
  {}
);
console.log('child pid:', p.pid);

p.on('exit', code => {
  console.log('exit:', code);
});
p.on('message', m => {
  console.log('message from child: ', m);
});
p.send('send from parent');

經過監聽 message 事件和調用 send 方法,咱們就能夠在父子進程間進行通訊了。至於通訊協議,咱們能夠本身設計或者直接使用 JSON,畢竟傳遞的都是一推字符串,很易用。

進程及信號量

除了咱們會和進程通訊外,實際上操做系統也會給進程發送一種叫作信號量的「消息」來告知進程某些事件發生了。通常會使用 kill [sid] [pid] 命令來發送信號量,一些常見的信號量以下:

kill [sid] [pid] process.on(evt) 說明
kill -1 / kill -HUP process.on('SIGHUP') 通常表示進程須要從新加載配置
kill -2 / kill -SIGINT / ctrl+c process.on('SIGINT') 退出進程
kill -15 / kill -TERM process.on('SIGTERM') 中止進程(kill 的默認信號)
kill -9 / kill -KILL 監聽不到 kernel 直接停掉進程,而且不通知進程

實際上 process 還能夠監聽 exit 事件,監聽 exit 事件和監聽信號量事件是不同的。exit 事件只有在執行 process.exit() 或者進程結束時纔會觸發。

因此,一個「優雅」的進程通常會綁定 exitSIGINTSIGTERM 事件,在 exit 事件中處理進程的清理工做,而後在 SIGTERMSIGINT 事件中調用 process.exit() 來讓進程真正退出。(若是你想耍流氓,能夠綁定 SIGTERMSIGINT 事件,而後啥也不作,這樣除非使用 kill -9,你的進程將永遠不會退出......)

除了經過 kill 命令發送信號量,咱們也可使用子進程的 .kill(sig) 方法來發送信號,好比:p.kill('SIGINT');或者 processprocess.kill(pid, 'SIGINT')

參考資料

相關文章
相關標籤/搜索