本文首發於個人博客,轉載請註明出處: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 相似於瀏覽器裏面的 JavaScript,是單線程的。那咱們如今須要理解 Node 的單線程究竟是什麼意思?
這裏說的單線程是指咱們所編寫的代碼運行在單線程上,實際上 Node 並非真的「單線程」。
當咱們執行 node app.js
時啓動了一個進程,可是這個進程並非只有一個線程,而是同時建立了不少個線程(好比:異步 IO 須要的一些 IO 線程)。以下圖所示(編號爲 92347 的進程一共有 5 個線程):
可是,仍然只有一個線程會運行咱們編寫的代碼。這就是 Node 單線程的含義。
Node 實際上從語言層面就不支持建立線程,咱們只有能力建立進程。這讓咱們的程序狀態單一,不用在乎狀態同步、死鎖、上下文切換開銷等等多線程編程中的頭疼問題。固然,咱們能夠經過進程間的通訊來共享一些「狀態」,但並非線程間共享的那種狀態。
單線程也會帶來一些問題:
沒法利用多核 CPU(只能得到一個 CPU 的時間分片)
錯誤會引發整個應用退出(整個應用就一個進程,掛了就掛了)
大量計算長時間佔用 CPU,致使阻塞線程內其餘操做(異步 IO 發不出調用,已完成的異步 IO 回調不能及時執行)
這些問題實際上都有對應的解決方案。咱們會使用 Master-Worker 的管理方式來建立和管理多個工做進程(工做進程數量通常會等於系統 CPU 的核心數量),保證應用可以充分利用多核 CPU,同時在發生錯誤時能夠優雅退出和自動重啓(好比 recluster 模塊)。咱們會新建立一個獨立進程來進行耗時的計算,而後將計算結果傳回給主線程。它們本質上都在使用 Node 提供的子進程功能。
在 Node 中,大致上有三種建立進程的方法:
exec / execFile
spawn
fork
exec(command, options, callback)
和 execFile(file, args, options, callback)
比較相似,會使用一個 Buffer
來存儲進程執行後的標準輸出結果,們能夠一次性在 callback
裏面獲取到。不太適合輸出數據量大的場景。
須要注意的是,exec
會首先建立一個新的 shell 進程出來,而後執行 command
;execFile
則是直接將可執行的 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(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 }
咱們這裏主要介紹下 detached
和 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
配置主要用來建立常駐的「後臺」進程,好比下面的代碼:
// 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(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
等事件(其餘事件有:error
、close
等)
訪問 stdin
、stdout
、stderr
屬性(這些屬性又是 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()
或者進程結束時纔會觸發。
因此,一個「優雅」的進程通常會綁定 exit
、SIGINT
、SIGTERM
事件,在 exit
事件中處理進程的清理工做,而後在 SIGTERM
、SIGINT
事件中調用 process.exit()
來讓進程真正退出。(若是你想耍流氓,能夠綁定 SIGTERM
、SIGINT
事件,而後啥也不作,這樣除非使用 kill -9
,你的進程將永遠不會退出......)
除了經過 kill
命令發送信號量,咱們也可使用子進程的 .kill(sig)
方法來發送信號,好比:p.kill('SIGINT')
;或者 process
的process.kill(pid, 'SIGINT')
。