如何使用spawn(),exec(),execFile()和fork()
對於單進程而言,Node.js的單線程和非阻塞特性表現地非常好。然而,對於處理功能越來越複雜的應用程序而言,一個單進程的CPU是遠遠無法滿足需要的。
無論你的服務器有多強大,單線程都是遠遠不夠用的。
事實上,Node.js的單線程特性並不意味着我們不能將其運行在多線程或者多服務器的環境中。
使用多進程是擴展Node.js應用程序的最佳實踐,Node.js就是爲構建具有多個節點的分佈式應用程序而設計的,這也是它爲什麼被命名爲Node的原因。可伸縮性已經融入到平臺中,而不應該在應用程序開發的後期纔開始考慮這部分內容。
在閱讀本文之前,你可能需要對Node.js的事件和流有一個很好的理解,推薦閱讀下面這兩篇文章:
child_process模塊
通過使用Node.js的child_process模塊,我們可以非常輕鬆地調用子進程,並在各個子進程之間通過消息系統相互通信。
child_process模塊使我們可以通過運行在其中的系統命令來訪問操作系統的功能。
我們可以控制子進程的輸入流,並監聽它的輸出流。我們還可以控制傳遞給底層操作系統的命令的參數,並對這些命令的輸出做任何我們想做的事情。例如,我們可以將一個命令的輸出作爲另一個命令的輸入(就像我們在Linux系統中做的那樣),因爲所有這些命令的輸入和輸出都可以用Node.js流的形式呈現。
注意,本文中所使用的示例都是基於Linux的。在Windows上,你需要將這些命令轉換成Windows上對應的部分。
在Node.js中,有四種不同的方式可以用來創建子進程:spawn(),fork(),exec()和execFile()。
接下來讓我們看看這四種方法之間的區別以及何時使用它們。
產生一個子進程
spawn函數會創建一個子進程,並在其中執行一個命令,我們可以通過它向該命令傳遞任何參數。例如,下面的代碼創建了一個新的進程並在其中執行pwd命令。
const { spawn } = require('child_process');
const child = spawn('pwd');
我們只需要引入child_process模塊,並使用其中的spawn函數,將命令作爲第一個參數傳入即可執行該命令。
spawn函數(上面代碼中的child對象)會返回ChildProcess的一個實例,該實例實現了EventEmitter API,這意味着我們可以直接在它上面註冊事件處理程序。例如,我們可以爲其註冊一個事件,當子進程退出時執行某些操作。
child.on('exit', function (code, signal) { console.log('child process exited with ' + `code ${code} and signal ${signal}`); });
上面的代碼提供了子進程退出或者終止時的code和signal。只有當子進程正常退出時,參數signal的值纔是null。
我們還可以爲ChildProcess實例註冊其它事件:disconnect、error、close和message。
- 當在父進程中手動調用child.disconnect函數時,disconnect事件被觸發。
- 如果無法創建或終止子進程,error事件被觸發。
- 當子進程的stdio流被關閉時,close事件被觸發。
- message事件是最重要的一個事件。當子進程使用process.send()函數發送消息時,該事件被觸發。父進程和子進程之間通過這種方式相互通信。下面我們會通過一個示例來講解這部分內容。
每一個子進程都可以通過child.stdin,child.stdout和child.stderr來獲取三個標準的stdoi流。
當這些流被關閉時,使用它們的子進程會觸發close事件。close事件與exit事件有所不同,因爲多個不同的子進程之間可以共享同一個stdio流,所以一個子進程的退出並不意味着其它的流已經關閉。
由於所有的流都是事件發射器,因此我們可以對附加在每個子進程上的stdio流監聽不同的事件。但是這與普通的進程不同,在子進程中,stdout/stderr流是可讀流,而stdin流是可寫流。這與我們在主進程中所遇到的情況正好相反。我們可以爲這些流使用的事件都是標準事件。最重要的是,我們可以對可讀流監聽data事件,其中包含命令的輸出或者執行命令時遇到的錯誤:
child.stdout.on('data', (data) => { console.log(`child stdout:\n${data}`); }); child.stderr.on('data', (data) => { console.error(`child stderr:\n${data}`); });
上面的兩個處理程序將這兩種情況的結果記錄到主進程的stdout和stderr中。當我們執行上面的spawn函數時,pwd命令的執行結構會被打印出來,然後子進程退出,code的值是0,意思是沒有發生任何錯誤。
我們可以將執行命令時的參數作爲調用spawn函數的第二個參數,該參數是一個數組,因此可以將執行命令時的所有參數作爲數組的元素傳遞給spawn函數。例如,要在當前目錄中執行find命令(僅列出文件),並使用-type f參數,我們可以這樣:
const child = spawn('find', ['.', '-type', 'f']);
如果在執行命令時發生錯誤,例如上面的例子中如果當前路徑無效,則child.stderr data事件將會被觸發,同時也會觸發exit事件,此時code的值爲1,表示有錯誤發生。錯誤的值實際上取決於主機操作系統和錯誤的類型。
子進程stdin是可寫流。我們可以使用它向命令發送一些數據。與任何其它的可寫流一樣,我們可以簡單地通過pipe函數來使用它。我們只需要簡單地將可讀流pipe到可寫流。由於主進程stdin是可讀流,因此我們可以將其pipe到子進程stdin流中。例如:
const { spawn } = require('child_process'); const child = spawn('wc'); process.stdin.pipe(child.stdin) child.stdout.on('data', (data) => { console.log(`child stdout:\n${data}`); });
在上面的示例中,子進程調用了wc命令,在Linux中該命令用來計算行數、單詞數和字符數。然後,我們將主進程stdio(可讀流)pipe到子進程stdin(可寫流)中。通過這樣的操作,我們可以得到一種標準的輸入模式,我們可以輸入內容然後通過Ctrl+D將內容傳遞給wc命令。
我們也可以在多個進程的標準輸入/輸出流之間使用pipe函數,就像我們在Linux命令行中所使用的那樣。例如,我們可以將find命令的stdout通過pipe傳給wc命令的stdin,用來對當前目錄中的文件進行計數:
const { spawn } = require('child_process'); const find = spawn('find', ['.', '-type', 'f']); const wc = spawn('wc', ['-l']); find.stdout.pipe(wc.stdin); wc.stdout.on('data', (data) => { console.log(`Number of files ${data}`); });
我在wc命令後面添加了-l參數,確保只計算文件中內容的行數。運行之後,上述代碼將對當前目錄下的所有文件進行計數。
Shell語法和exec函數
默認情況下,spawn函數不會創建一個shell來執行我們傳給它的命令。這使得它比exec函數效率更高,exec函數會創建shell。與spawn相比,exec函數還有另一個區別,它會緩衝命令的輸出,並將整個結果傳遞給回調函數(而spawn函數則使用流來傳遞命令的結果)。
下面是我們用exec函數實現的前面find | wc命令的例子。
const { exec } = require('child_process'); exec('find . -type f | wc -l', (err, stdout, stderr) => { if (err) { console.error(`exec error: ${err}`); return; } console.log(`Number of files ${stdout}`); });
因爲exec函數使用shell來執行命令,因此我們可以直接在其中使用shell語法pipe來連接兩個命令。
注意,如果我們使用從外部動態輸入的字符串作爲shell語法來執行命令,則可能會帶來安全風險。用戶可以簡單地使用shell語法字符對命令進行注入攻擊,例如command + '; rm -rf ~'(這將會刪除當前目錄下的所有文件)。
exec函數緩衝命令的輸出,並將其作爲stdout參數傳遞給回調函數(exec的第二個參數),我們使用該參數打印命令的輸出結果。
如果你希望在命令中使用shell語法,並且命令返回的結果數據量很小,那麼exec函數是個不錯的選擇。(請記住,exec在返回結果之前會將整個數據緩衝在內存中。)
當命令返回的結果數據量比較大時,最好使用spawn函數,因爲數據將與標準IO對象一起被傳遞。
如果需要的話,我們可以使產生的子進程繼承自父進程的標準IO對象,但更重要的是,我們也可以在spawn函數中使用shell語法。下面是在spawn函數中使用find | wc命令:
const child = spawn('find . -type f | wc -l', { stdio: 'inherit', shell: true });
由於上面代碼中的stdio: 'inherit'選項,當我們運行時,子進程會繼承主進程的stdin,stdout和stderr。這將導致在主進程的stdout流中觸發子進程的數據事件處理程序,從而使腳本立即輸出結果。
另外由於上面代碼中的shell: true選項,我們可以在傳入的命令中使用shell語法,就像我們在exec函數中所做的那樣。除此之外,我們仍然能夠使用spawn函數數據流的優勢。這真是兩全其美。
除了shell和stdio這兩個選項之外,child_process函數還有一些其它不錯的選項。例如,我們可以使用cwd選項來指定當前腳本的工作目錄。下面這個例子使用spawn函數對我的用戶目錄下的Downloads文件夾中的所有文件進行計數。這裏的cwd選項指定腳本要計算的文件所在的目錄爲~/Downloads:
const child = spawn('find . -type f | wc -l', { stdio: 'inherit', shell: true, cwd: '/Users/samer/Downloads' });
我們可以使用的另一個選項是env,它用來爲新產生的子進程指定可用的環境變量。此選項默認爲process.env,任何命令都可以訪問當前進程的環境變量。如果想要改寫環境變量的值,我們可以簡單地將一個空對象傳遞給env選項,或者指定一個新的值作爲一個唯一的環境變量:
const child = spawn('echo $ANSWER', { stdio: 'inherit', shell: true, env: { ANSWER: 42 }, });
上面代碼中的echo命令無法訪問父進程的環境變量。例如,它無法訪問$HOME,但是它可以訪問$ANSWER,因爲我們將$ANSWER通過env選項指定爲自定義的環境變量。
最後一個重要的選項是detached,它使子進程獨立於父進程運行。
假設我們有一個timer.js文件,它在事件循環中保持運行:
setTimeout(() => { // keep the event loop busy }, 20000);
我們可以使用detached選項讓它在後臺運行:
const { spawn } = require('child_process'); const child = spawn('node', ['timer.js'], { detached: true, stdio: 'ignore' }); child.unref();
從父進程分離子進程的具體行爲取決於操作系統。在Windows中,分離的子進程具有自己獨立的的控制檯窗口,而在Linux中,分離的子進程會創建一個新的進程組和會話標籤。
如果在分離的子進程上調用了unref函數,則父進程可以獨立於子進程退出。如果子進程正在執行一個時間比較長的任務,這個功能會很有用。要讓子進程在後臺保持運行,我們還需要將子進程的stdio選項也配置爲獨立於父進程。
上面的示例通過分離的子進程在後臺執行一個Node腳本(timer.js),並且忽略了父進程的stdio文件描述符,這樣當父進程被終止時,子進程仍然可以在後臺保持運行。
execFile函數
如果你想在不使用shell的情況下執行一個文件,可以使用execFile函數。它與exec函數的行爲完全相同,只是不使用shell,這使得execFile函數的執行效率更高。在Windows中,某些文件無法單獨執行,例如.bat或.cmd文件。這些文件不能使用execFile函數執行,不過可以使用exec或spawn函數並將shell選項設置爲true來執行它們。
*Sync函數
child_process模塊中的spawn,exec和execFile函數都有對應的同步版本,當調用這些函數時,它會阻塞當前程序的執行直到子進程退出纔會繼續下一步。
const {
spawnSync,
execSync,
execFileSync,
} = require('child_process');
如果你試圖簡化腳本編寫或者執行任何腳本任務,這些同步版本可能會很有用,但應該儘量避免使用它們。
fork()函數
fork函數是spawn函數的變體,它用來產生一個node進程。spawn和fork之間的最大區別在於,當使用fork時,子進程的通信信道會被建立,因此我們可以在子進程中使用send函數與全局對象process一起在父進程和子進程之間交換信息。我們通過EventEmitter模塊接口來實現這一操作。下面是具體的例子:
文件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);
在上面的parent.js文件中,我們fork了child.js(這將使用node命令執行該文件),然後監聽message事件。每當child.js使用process.send發送數據時,message事件都會被觸發。在child.js中,每隔一秒都會調用process.send方法。
要將消息從parent傳遞給child,我們可以在對象forked上執行send函數,然後在child.js中,我們監聽全局對象process的message事件。
當執行上面程序中的parent.js文件時,它首先向下發送{ hello: 'world' }對象,forked的子進程打印一個消息(Message from parent: { hello: 'world' }),然後child.js將每秒發送一個遞增的數值給父進程打印消息(Message from child{ counter: 0 })。
下面讓我們來一個有關fork函數的更實際的例子。
假設我們有一個http服務器,用來處理兩個endpoint。其中一個endpoint(/compute)耗時較長,它需要幾秒鐘才能響應。我們可以使用一個長的for循環來模擬它:
const http = require('http'); const longComputation = () => { let sum = 0; for (let i = 0; i < 1e9; 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);
這個程序有一個很大的問題。當請求/compute時,由於事件循環忙於處理那個長的for循環操作,因此服務器將無法處理其它的請求。
有幾種方法可以解決此問題,不過有一種解決辦法適用於所有的操作,我們可以使用fork將計算移至另一個進程中。
首先,我們將整個longComputation函數移至一個新的文件中,並通過主進程的消息來調用該函數:
文件computer.js:
const longComputation = () => { let sum = 0; for (let i = 0; i < 1e9; i++) { sum += i; }; return sum; }; process.on('message', (msg) => { const sum = longComputation(); process.send(sum); });
現在,我們不需要在主進程的事件循環中進行一個很費時的操作,我們可以fork文件compute.js,並使用message接口在服務器和fork的進程之間傳遞消息。
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);
當使用上述代碼請求/compute時,我們只需要簡單地向fork的進程發送一條消息即可執行那個很費時的操作,而主進程的事件循環不會被阻塞。
一旦fork的進程完成了那個很費時的操作,它可以通過process.send將結果發送給主進程。
在父進程中,我們監聽fork的進程的message事件。當該事件被觸發後,我們獲取到sum值,然後通過http返回給請求者。
當然,上面的代碼中,我們可以fork的進程的數量是受限制的,但是當我們執行它並通過http請求一個耗時較長的endpoint時,主服務器不會被阻塞從而可以繼續響應其它的請求。