轉:Node.js軟肋之CPU密集型任務

文章來自於: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

Node.js的先天條件

網絡編程策略

上世紀90年代提出了一個著名的C10K問題。大概意思是當用戶數超過1萬時,不少沒設計好的網絡服務程序性能將急劇降低,甚至癱瘓。這時候升級硬件也無論用了,問題的根源是系統處理請求的策略,有再多的硬件資源它也用不起來。後來人們總結出了四種典型的網絡編程策略:github

  1. 服務器爲每一個客戶端請求分配一個線程/進程,使用阻塞式I/O。Java就是這種策略,Apache也是,這種策略仍是不少交互式應用的首選。由於阻塞,這種策略很難實現高性能,但很是簡單,能夠實現複雜的交互邏輯。
  2. 服務器用一個線程處理全部客戶端請求,使用非阻塞的I/O及事件機制。node.js採用的就是這種策略。這種策略實現起來比較簡單,方便移植,也能提供足夠的性能,但沒法充分利用多核CPU資源。
  3. 服務器會分配多個線程來處理請求,但每一個線程只處理其中一組客戶端的請求,使用非阻塞的I/O及事件機制。這是對第二種策略的簡單改進,在多線程併發上容易出現bug。
  4. 服務器會分配多個線程來處理請求,但每一個線程只處理其中一組客戶端的請求,使用異步I/O。這種策略在支持異步I/O的操做系統上性能很是高,但實現起來很難,主要用在windows平臺上。

由於大多數網站的服務器端都不會作太多的計算,它們只是接收請求,交給其它服務(好比文件系統或數據庫),而後等着結果返回再發給客戶端。因此聰明的Node.js針對這一事實採用了第二種策略,它不會爲每一個接入請求繁衍出一個線程,而是用一個主線程處理全部請求。避開了建立、銷燬線程以及在線程間切換所需的開銷和複雜性。這個主線程是一個很是快速的event loop,它接收請求,把須要長時間處理的操做交出去,而後繼續接收新的請求,服務其餘用戶。下圖描繪了Node.js程序的請求處理流程:web

主線程event loop收到客戶端的請求後,將請求對象、響應對象以及回調函數交給與請求對應的函數處理。這個函數能夠將須要長期運行的I/O或本地API調用交給內部線程池處理,在線程池中的線程處理完後,經過回調函數將結果返回給主線程,而後由主線程將響應發送給客戶端。那麼event loop是如何實現這一流程的呢?這要歸功於Node.js平臺的V8引擎libuv數據庫

 

Event Loop和Tick

每一個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運行時加到隊列中的事件。安全

那麼這個隊列中的東西都是誰放進來的呢?

  • process.nextTick
  • setTimeout/setInterval
  • I/O (來自fs、net等)
  • crypto中的CPU密集型函數,好比crypto streams、pbkdf2和PRNG
  • 全部使用libuv工做隊列異步調用C/C++庫的本地模塊

當Event loop遇到CPU密集型任務

由於event loop在處理全部的任務/事件時,都是沿着事件隊列順序執行的,因此在其中任何一個任務/事件自己沒有完成以前,其它的回調、監聽器、超時、nextTick()的函數都得不到運行的機會,由於被阻塞的event loop根本沒機會處理它們,此時程序最好的狀況是變慢,最糟的狀況是停滯不動,像死掉同樣。因此當Node.js遇到高CPU佔用率的任務時,event loop會被阻塞住,造成下面這種局面:

被阻塞的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/內核,那麼每一個線程均可以獲得一個不一樣本身的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,還容許你給它編寫本地擴展,因此有不少種不一樣的辦法可讓程序的代碼並行運行。

把CPU密集型任務分給子線程

自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

使用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);
})();

參考文獻

  1. Why you should use Node.js for CPU-bound tasks,Neil Kandalgaonkar,2013.4.30;
  2. TAGG項目文檔
  3. Understanding process.nextTick(),Kishore Nallan,2013.5.13
  4. Node v0.10.0 changes from 0.8:FASTER PROCESS.NEXTTICK
  5. What exactly is a Node.js event loop tick? ,StackOverflow,2013.11.6
  6. Fully Loaded Node – A Node.JS Holiday Season, part 2,Lloyd Hilaiel,2012.11.20
  7. 本文源碼
相關文章
相關標籤/搜索