這篇文章中,我們希望學習到 :
在開發
nodejs
時,如果遇到cpu
密集型的任務時,要如何處理 ?
首先我們先來複習一下nodejs
的機制一下。
我們都知道nodejs
是屬於單一執行序架構
,在其它的語言裡,每當有一個請求進來時,它們都會產生一個執行緒
,但nodejs
則否,他是用一個執行緒
就來處理所有的請求,而他的背後就是有個事件機制設計
才能做到這種方法。請參考這篇。
但為什麼要設計成用
單一執行序架構
呢?
這邊我們要先來說說I/O
操作。
I/O 問題
I/O
就是電腦中資料與記憶體、硬碟或網路的輸入和輸出,他基本上是電腦作業裡最慢的事物,I/O
操作基本上對 cpu 而言通常負擔很小,但是問題就在於它很耗時
。
傳統的阻塞I/O
設計方式如下 :
data = getData();
print(data);
我們假設getData
是要去讀取一個檔案,而這時會等到getData
執行完後,就資料傳送給data
時我們才可以使用。
那假設我們這個getData
要讀很久,那這樣的話其它的請求著麼辦 ?
傳統的作法就會像下面這張圖一樣,系統會分別的開啟不同的執行緒來進行處理,如此一來,當有某個執行緒因I/O
操作而阻塞時,就不會影響到其它的請求。
這種作法的缺點就在於 :
開啟執行緒的成本不低,它會消耗記憶體而且引發環境切換
那node
他著麼處理呢 ?
他使用單一執行緒機制
,而他的執行緒中有一個機制被稱為事件機制
,簡單的說事件機制可以將所有的請求收集起來,並且將需要長時間處理的工作丟出去工作給其它人做(I/O),然後繼續接收新的請求,就如同下圖一樣,這樣的優點就在於,他可以接受更多的請求,,而不會因為一個長時間的I/O,其它東西就都卡住不能動。
但他也是有缺點的 :
它無法充分利用多核 cpu 資源
當 Event loop 遇到 CPU 密集型任務會發生什麼事 ?
上面有提到單一執行緒機制
有一個缺點,那就是無法統分利用cpu資源,這是什麼意思呢 ?
傳統的方式,每個請求分配一個執行緒,他都可以得到一個不同於自已的 cpu,在這種情況下多執行緒可以大大的提高資源使用效率。
而這也代表的單執行緒
他就只能占用一個 cpu ,並且如果某個任務是很吃 cpu 的工作時,這執行緒就會被那個任務占用,導致其它的任務、請求都無法執行。
我們下面簡單的寫一段程式碼來看看會發生什麼事情。
下面這段程式碼裡,我們將簡單的建立一個server
,它一收到請求,就會開始計算費波南西數列
,這種運算基本上就是一個很耗 CPU 的工作。
const http = require('http');
http.createServer(function (req, res) {
console.log("master:" + process.pid);
res.writeHead(200);
res.write(fib(46).toString());
res.end();
}).listen(8000, function () {
console.log('started');
});
function fib(n) {
return n > 1 ? fib(n - 1) + fib(n - 2) : 1;
}
然後當我們啟動這個 server 後,你會注意到,第一個請求發送以後,你會在 console 看到下面的輸出 :
master:68375
也就是打印出這個process
的 pid ,但它會還沒回傳值給第一個請求,然後這時如果你在發送一個請求,你會注意到它沒有打印出 master:68375
這段資訊。
為什麼呢 ? 這就是我們上面說的node屬於單一執行緒機制
,他就只能占用一個 cpu 並且因為第一個請求的運算還在執行,導致其它的請求都會無法執行,只有等到第一個請求結束後,才會繼續執行。
注意
process 進程
與thread 執行緒
是兩個不一樣的東西
我們這邊簡單的說明一下process進程
與thread執行緒
的關係, 首先在傳統的系統中進程是個容器,而執行緒就是容器中的工作單位
。
進程
就是我們在 windows 系統下,打開工作管員裡的processes
,你看到一行一行的就都是進程,而且你打開每個chrome頁面
他都是一個進程,而進程間的通訊則使用IPC
方法。
執行緒
是包含在進程內的工作單位,在同一個進程裡,所有的執行緒都共享系統資源,但他們同時也都有自已的stack
和context
,而且可以共享變數。
那要如何解決呢 ?
開一個新的 process 來處理
在 javascript 中我們可以使用一個叫Web Worker
的東西來處理,可以看一下筆者年輕時寫的這篇文章HTML5之走在平行時空的Web Worker。
而在 node 中我們可以使用child_process
,這個模組可以幫助我們建立child process
,中文來說就是子進程
,我們使用這模組中的fork
來建立時,它同時會提供IPC通道
讓我們可以使用訊息來進行process 與 process 的溝通
。
接下來我們就是要將費波南西數列的運算,丟到另一個子進程中來處理,這樣我們的請求也就可以同時的處理了。
下面為我們修改後的程式碼,我們會使用child_process.fork('./subset.js')
來建立子進程,並且我們會使用send
方法將資料丟到子進程中,然後在用on('message')
來監聽回傳結果。
這種寫法實際執行測試後,你會發生每當你發一個請求時,都會打印出master:68375
,這也代表我們的執行緒不會在塞住了,而且你在實際丟兩個請求來測試有用子進程的執行速度,你會發現快了兩倍。
const child_process = require('child_process');
http.createServer(function (req, res) {
console.log("master:" + process.pid);
const child = child_process.fork('./subprocess.js');
child.send({ value: 45 });
child.on('message', function (m) {
res.writeHead(200);
res.write(m.result.toString());
res.end();
})
}).listen(8000, function () {
console.log('started');
});
// subprocess.js
function fibo(n){
return n>1 ? fibo(n-1) + fibo(n-2) : 2;
}
process.on('message', function (message) {
console.log("child:" + process.pid)
process.send({result: fibo(message.value)});
})
但是呢 ? 上面這種寫法還是有個缺點,那就是代表每一個請求都會多開一個子進程,這樣也代表這請求一多就會開了一堆子進程,這樣是很浪費資源的,所以接下來我們會修改一下增加一個 pool 來管理這些子進程
,好處在於可以節省資源,而另一個好處可以阻斷服務攻擊 Dos
。
這個我們就留到下一篇cluster
時在來說明囉。