文章來自於:http://www.infoq.com/cn/articles/nodejs-weakness-cpu-intensive-taskshtml
Node.js在官網上是這樣定義的:「一個搭建在Chrome JavaScript運行時上的平臺,用於構建高速、可伸縮的網絡程序。Node.js採用的事件驅動、非阻塞I/O模型使它既輕量又高效,是構建運行在分佈式設備上的數據密集型實時程序的完美選擇。」Web站點早已不只限於內容的呈現,不少交互性和協做型環境也逐漸被搬到了網站上,並且這種需求還在不斷地增加。這就是所謂的數據密集型實時(data-intensive real-time)應用程序,好比在線協做的白板,多人在線遊戲等,這種web應用程序須要一個可以實時響應大量併發用戶請求的平臺支撐它們,這正是Node.js擅長的領域。node
用Node.js處理I/O密集型任務至關簡單,只須要調用它準備好的異步非阻塞函數就好了。然而數據密集型實時(data-intensive real-time)應用程序並非只有I/O密集型任務,當碰到CPU密集型任務時,好比要對數據加解密(node.bcrypt.js),數據壓縮和解壓(node-tar),或者要根據用戶的身份對圖片作些個性化處理,這時候該怎麼辦呢?咱們先來了解下Node.js自身的編程模型。git
上世紀90年代提出了一個著名的C10K問題。大概意思是當用戶數超過1萬時,不少沒設計好的網絡服務程序性能將急劇降低,甚至癱瘓。這時候升級硬件也無論用了,問題的根源是系統處理請求的策略,有再多的硬件資源它也用不起來。後來人們總結出了四種典型的網絡編程策略:github
由於大多數網站的服務器端都不會作太多的計算,它們只是接收請求,交給其它服務(好比文件系統或數據庫),而後等着結果返回再發給客戶端。因此聰明的Node.js針對這一事實採用了第二種策略,它不會爲每一個接入請求繁衍出一個線程,而是用一個主線程處理全部請求。避開了建立、銷燬線程以及在線程間切換所需的開銷和複雜性。這個主線程是一個很是快速的event loop,它接收請求,把須要長時間處理的操做交出去,而後繼續接收新的請求,服務其餘用戶。下圖描繪了Node.js程序的請求處理流程:web
主線程event loop收到客戶端的請求後,將請求對象、響應對象以及回調函數交給與請求對應的函數處理。這個函數能夠將須要長期運行的I/O或本地API調用交給內部線程池處理,在線程池中的線程處理完後,經過回調函數將結果返回給主線程,而後由主線程將響應發送給客戶端。那麼event loop是如何實現這一流程的呢?這要歸功於Node.js平臺的V8引擎和libuv。數據庫
每一個Node程序的主線程都有一個event loop,JavaScript代碼全在這個單線程下運行。全部的I/O操做以及對本地API的調用,或者是異步的(藉助程序所在平臺的機制),或者運行在另外的線程中。這全都是經過libuv處理的。因此當socket上有數據過來,或本地API函數返回時,須要有種同步的方式調用對剛發生的這一特定事件感興趣的JavaScript函數。編程
在發生事件的線程中直接調用JS函數是不安全的,由於那樣也會遇到常規多線程程序遇到的問題,競態條件、非原子操做的內存訪問等等。因此要以一種線程安全的方式把事件放在隊列中,若是寫成代碼,大體應該是這樣的:windows
lock (queue) { queue.push(event); }
而後在執行JavaScript的主線程中(即event loop的c代碼):api
while (true) { // tick開始 lock (queue) { var tickEvents = copy(queue); // 將當前隊列中的條目複製的線程自有的內存中 queue.empty(); // ..清空共享的隊列 } for (var i = 0; i < tickEvents.length; i++) { InvokeJSFunction(tickEvents[i]); } // tick結束 }
while (true)
(在真正的node源碼中並非這樣的;這裏只是爲了說明)表示event loop。裏面的for
爲隊列中的每一個事件調用JS函數。Event loop在每一個tick中都會調用與外部事件相關聯的零個或多個回調函數,一旦隊列被清空,而且最後一個函數返回後,tick就結束了。而後回到開始(下一個tick),從新開始檢查其它線程在JavaScript運行時加到隊列中的事件。安全
那麼這個隊列中的東西都是誰放進來的呢?
由於event loop在處理全部的任務/事件時,都是沿着事件隊列順序執行的,因此在其中任何一個任務/事件自己沒有完成以前,其它的回調、監聽器、超時、nextTick()
的函數都得不到運行的機會,由於被阻塞的event loop根本沒機會處理它們,此時程序最好的狀況是變慢,最糟的狀況是停滯不動,像死掉同樣。因此當Node.js遇到高CPU佔用率的任務時,event loop會被阻塞住,造成下面這種局面:
下面給出兩段代碼,看一下event loop被阻塞住時的具體表現。
這段代碼中的event loop以最快的速度運轉,不斷地向控制檯中輸出.
:
代碼清單1. 快速行進的event loop
(function spinForever () { process.stdout.write("."); process.nextTick(spinForever); })();
而後咱們在這段代碼中再加上一個計算斐波那契數列的任務。
代碼清單2. 被高CPU佔用率計算阻塞的event loop
function fibo (n) { return n > 1 ? fibo(n - 1) + fibo(n - 2) : 1; } (function fiboLoop () { process.stdout.write(fibo(45).toString()); process.nextTick(fiboLoop); })(); (function spinForever() { process.stdout.write("."); process.nextTick(spinForever); })();
計算斐波那契數列是一個CPU密集型的任務,event loop要在計算結果出來後纔有機會進入下一個tick,執行輸出.
的簡單任務,感受就像服務器死掉了同樣。在個人機器上計算斐波那契數列時,取值45
能夠明顯感受到程序的停滯,你能夠根據本身的CPU性能調節該值。
process.nextTick()
在Node 0.8(及以前)的版本中,
process.nextTick()
中指定的函數一般會比其它任何I/O先被調用,然而並不能保證必定會這樣。但不少開發人員(包括Node.js的內部團隊)開始用process.nextTick
實現「稍後再作,但要在任何真正的I/O執行以前」。然而在負載比較大時,由於I/O不少,可能致使nextTick
被別的東西佔先,從而引起一些很怪異的錯誤。因此在v.0.10以後,netxtTick
的語義被改了,那些函數變成在每次從C++進入JavaScript的調用以後立刻運行。也就是說,若是你的JavaScript代碼調用了process.nextTick
,只要代碼即將運行完成時,在回到event loop以前那個回調就會被調用。然而還有不少程序用遞歸調用
process.nextTick
,以避免長期運行的任務搶佔了I/O event loop。爲了避免把這些程序都搞垮,Node如今會輸出一個廢棄警告,提示你在這些任務中使用setImmediate
。不過對咱們這個例子來講,這兩個版本之間的差別沒有影響。
最開始,線程只是用於分配單個處理器處理時間的一種機制。但假如操做系統自己支持多個CPU/內核,那麼每一個線程均可以獲得一個不一樣本身的CPU/內核,實現真正的「並行運算」。在這種狀況下,多線程程序能夠提升資源使用效率。Node.js是單線程程序,它只有一個event loop,也只佔用一個CPU/內核。如今大部分服務器都是多CPU或多核的,當Node.js程序的event loop被CPU密集型的任務佔用,致使有其它任務被阻塞時,卻還有CPU/內核處於閒置的狀態,形成資源的浪費。
你能夠再次運行代碼清單2中的代碼,啓動top
(或者Windows的任務管理器)查看CPU的使用狀況。我這臺Mac上是一個雙核的i7處理器,當node的CPU佔用率在100%左右浮動時,系統的CPU佔用率卻只有28%左右。
既然Node.js程序幾乎徹底運行在單個CPU/內核上,因此咱們須要作些額外的工做才能提高它的擴展性。Node.js提供了一組管理進程的API,還容許你給它編寫本地擴展,因此有不少種不一樣的辦法可讓程序的代碼並行運行。
自Node.js誕生之日起,就有人質疑它的單線程模型面對協做式多任務時的處理能力。但這個實際上並非Node.js產生的新問題,在JavaScript中由來已久,能夠採用Web Worker模式應對。所以咱們的問題就變成了如何在Node.js程序中實現Web Worker模式,首先來看一個在Node.js中控制進程的API。
child_process.fork()
Node.js中有管理子進程的child_process
模塊,能夠用fork()
方法建立新的子進程實例。這個子進程是用IPC通道添加的,能夠經過.send(message)
函數發送消息給它,用.on('message')
監聽它發送的消息。而在子進程中,能夠用process.on('message',callback)
監聽父進程發送的消息,並經過process.send(message)
向父進程發送消息。接下來咱們fork()
一個子進程,把計算斐波那契數列的任務交給它,這須要兩個文件。
代碼清單3. 主進程文件forkParent.js
var cp = require('child_process'); var child = cp.fork(__dirname+'/forkChild.js'); child.on('message', function(m) { process.stdout.write(m.result.toString()); }); (function fiboLoop () { child.send({v:40}); process.nextTick(fiboLoop); })(); (function spinForever () { process.stdout.write("."); process.nextTick(spinForever); })();
在主進程中用cp.fork()
建立了子進程child
,並用child.on('message', callback)
監聽子進程發來的消息,輸出計算結果。如今的fiboLoop()
也再也不執行具體的計算操做,只是用child.send({v:40});
不停的發消息給子進程。
代碼清單4. 計算斐波那契數列的子進程文件forkChild.js
function fibo (n) { return n > 1 ? fibo(n - 1) + fibo(n - 2) : 1; } process.on('message', function(m) { process.send({ result: fibo(m.v) }); });
子進程文件很簡單,仍是原來那個計算用的函數,以及一個監聽消息的process.on('message',callback)
,計算結果並用process.send(message, [sendHandle])
發送消息給父進程。此外,父進程和子進程二者之間發送消息是同步的,因此兩邊是有來有往,工做開展地井井有理。運行node forkParent.js
,結果跟咱們預期的同樣,輸出.
的任務再也不受到阻塞,歡快地在屏幕上刷了一大堆.
,而後每隔一段輸出一個165580141
。咱們再用top
查看一下資源的使用狀況,會發現有兩個node進程,CPU佔用率也增長了不少。
實際上fork()
獲得的並非子進程,而是一個全新的Node.js程序實例。而且每一個新實例至少須要30ms的啓動時間和10M內存,也就是說經過fork()
繁衍進程,不光是充分利用了CPU,也須要不少內存,因此不能fork()
太多。若是你有興趣,能夠再fork()
一個或幾個進程,並建立跟這個(些)進程交互的函數,查看下資源佔用狀況。
使用cluster模塊能夠充分利用多核CPU資源,在Node.js的0.6版被歸入核心模塊,但目前(V0.10.26)仍處於實驗狀態。藉助cluster模塊,Node.js程序能夠同時在不一樣的內核上運行多個」工人進程「,每一個」工人進程「作的都是相同的事情,而且能夠接受來在同一個TCP/IP端口的請求。相對於在Ngnix或Apache後面啓動幾個Node.js程序實例而言,cluster用起來更加簡單便捷。雖然cluster模塊繁衍線程實際上用的也是child_process.fork
,但它對資源的管理要比咱們本身直接用child_process.fork
管理得更好。下面是用cluster實現的代碼:
代碼清單5. 用cluster繁衍工人進程計算斐波那契數列
function fibo (n) { return n > 1 ? fibo(n - 1) + fibo(n - 2) : 1; } var cluster= require('cluster'); if (cluster.isMaster) { cluster.fork(); } else { (function fiboLoop () { process.stdout.write(fibo(40).toString()); process.nextTick(fiboLoop); })(); } (function spinForever () { process.stdout.write("."); process.nextTick(spinForever); })();
代碼很簡單,若是是主進程,就fork()
工人進程,這裏也能夠用循環遍歷,根據CPU內核的個數繁衍相應數量甚至更多的進程;若是是工人進程,就執行fiboLoop
。你能夠自行用top
查看一下資源佔用狀況,你會發現這種方式用得資源比上面那種方式要少。
雖然cluster模塊能夠充分利用資源,用起來也比較簡單,但它只是解決了負載分配的問題。但其實作得也不是特別好,在0.10版本以前,cluster把負載分配的工做交給了操做系統,然而實踐證實,最終負載都落在了兩三個進程上,分配並不均衡。因此在0.12版中,cluster改用round-robin方式分配負載。詳情請參見這裏。
除了Node.js官方提供的API,Node.js社區也爲這個問題貢獻了幾個模塊。
好比Mozilla Identity團隊爲Persona開發的node-compute-cluster。這個模塊能夠繁衍和管理完成特定計算的一組進程。你能夠設定最大進程數,而後由node-compute-cluste根據負載肯定進程數量。它還會追蹤運行進程的數量,以及工做完成的平均時長等統計信息,方便你分析系統的處理能力。下面是一個簡單的例子:
const computecluster = require('compute-cluster'); // 分配計算集羣 var cc = new computecluster({ module: './worker.js' }); // 並行運行工做 cc.enqueue({ input: 35 }, function (error, result) { console.log("35:", result); }); cc.enqueue({ input: 40 }, function (error, result) { console.log("40:", result); });
文件worker.js中的代碼應該響應message
事件處理隊列中的任務:
process.on('message', function(m) { var output; var output = fibo(m.input); process.send(output); });
還有功能強大的threads_a_gogo。參考文獻中的第一篇文章介紹了一個拼字遊戲解密程序LetterPwn,本文在很大程度上是受這篇文章的啓發而寫的,其中就是用threads_a_gogo管理CPU密集型計算線程的。因爲篇幅所限,就再也不展開介紹了。不過最後咱們用threads_a_gogo線程池的例子做爲結尾:
function fibo (n) { return n > 1 ? fibo(n - 1) + fibo(n - 2) : 1; } var numThreads= 10; var threadPool= require('threads_a_gogo').createPool(numThreads).all.eval(fibo); threadPool.all.eval('fibo(40)', function cb (err, data) { process.stdout.write(" ["+ this.id+ "]"+ data); this.eval('fibo(40)', cb); }); (function spinForever () { process.stdout.write("."); process.nextTick(spinForever); })();