一個Node.JS 的進程只會運作在單一的實體核心上,就是因為這一點,在開發可擴充的伺服器的時候就需要格外的注意。
因為有一系列穩定的API,加上原生擴充的開發來管理流程,所以有很多不同的方法來設計一個可以並行的Node.JS運用。在這篇文章裡,我們就來比較下這些可能的架構。
這篇文章同時也介紹compute-cluster 模組:一個小型的Node.JS函式庫,可以用來很方便的管理進程,從來二線分散式運算。
遇到的問題
我們在Mozilla Persona的專案中需要可以處理大量不同特徵的請求,所以我們嘗試使用Node.JS。
為了不影響使用者體驗,我們設計的‘Interactive' 請求只需要輕量級的計算消耗,但是提供更快地反映時間使得UI沒有卡殼的感覺。相較之下,‘Batch'操作大概需要半秒的處理時間,而且有可能由於其他的原因,會有更長的延遲。
為了更好的設計,我們找了很多符合我們目前需求的方法來解決。
考慮到擴充性和成本,我們列出以下關鍵需求:
經過以上幾點我們可以清楚、有目標的去篩選
方案一:直接在主執行緒處理.
當主執行緒直接處理資料的時候,結果很不好:
你不能充分利用多核心CPU的優勢,在互動的請求/回應中,必須等待當前請求(或回應)處理完畢,毫無優雅可言。
這個方案唯一的優點是:夠簡單
function myRequestHandler(request, response) [ // Let's bring everything to a grinding halt for half a second. var results = doComputationWorkSync(request.somesuch); }
在 Node.JS 程式中,希望同時處理多個請求,又想同步進行處理,那你準備弄個焦頭爛額吧。
方法 2: 是否使用非同步處理.
如果在背景使用非同步的方法來執行是否一定會有很大的效能改善呢?
答案是不一定.它取決於後台運行是否有意義
例如下面這種情況:如果在主執行緒上使用javascript或本地程式碼進行計算時,效能並不比同步處理更好時,就不一定需要在後台用非同步方法去處理
請閱讀以下程式碼
function doComputationWork(input, callback) { // Because the internal implementation of this asynchronous // function is itself synchronously run on the main thread, // you still starve the entire process. var output = doComputationWorkSync(input); process.nextTick(function() { callback(null, output); }); } function myRequestHandler(request, response) [ // Even though this *looks* better, we're still bringing everything // to a grinding halt. doComputationWork(request.somesuch, function(err, results) { // ... do something with results ... });
}
关键点就在于NodeJS异步API的使用并不依赖于多进程的应用
方案三:用线程库来实现异步处理。
只要实现得当,使用本地代码实现的库,在 NodeJS 调用的时候是可以突破限制从而实现多线程功能的。
有很多这样的例子, Nick Campbell 编写的 bcrypt library 就是其中优秀的一个。
如果你在4核机器上拿这个库来作一个测试,你将看到神奇的一幕:4倍于平时的吞吐量,并且耗尽了几乎所有的资源!但是如果你在24核机器上测试,结果将不会有太大变化:有4个核心的使用率基本达到100%,但其他的核心基本上都处于空闲状态。
问题出在这个库使用了NodeJS内部的线程池,而这个线程池并不适合用来进行此类的计算。另外,这个线程池上限写死了,最多只能运行4个线程。
除了写死了上限,这个问题更深层的原因是:
内建线程机制的组件库在这种情况下并不能有效地利用多核的优势,这降低了程序的响应能力,并且随着负载的加大,程序表现越来越差。
方案四:使用 NodeJS 的 cluster 模块
NodeJS 0.6.x 以上的版本提供了一个cluster模块 ,允许创建“共享同一个socket”的一组进程,用来分担负载压力。
假如你采用了上面的方案,又同时使用 cluster 模块,情况会怎样呢?
这样得出的方案将同样具有同步处理或者内建线程池一样的缺点:响应缓慢,毫无优雅可言。
有时候,仅仅添加新运行实例并不能解决问题。
方案五:引入 compute-cluster 模块
在 Persona 中,我们的解决方案是,维护一组功能单一(但各不相同)的计算进程。
在这个过程中,我们编写了 compute-cluster 库。
这个库会自动按需启动和管理子进程,这样你就可以通过代码的方式来使用一个本地子进程的集群来处理数据。
使用例子:
const computecluster = require('compute-cluster'); // allocate a compute cluster var cc = new computecluster({ module: './worker.js' }); // run work in parallel cc.enqueue({ input: "foo" }, function (error, result) { console.log("foo done", result); }); cc.enqueue({ input: "bar" }, function (error, result) { console.log("bar done", result); });
fileworker.js 中响应了 message 事件,对传入的请求进行处理:
process.on('message', function(m) { var output; // do lots of work here, and we don't care that we're blocking the // main thread because this process is intended to do one thing at a time. var output = doComputationWorkSync(m.input); process.send(output); });
無需更改呼叫程式碼,compute-cluster 模組就可以和現有的非同步API整合起來,這樣就能以最小的程式碼量換來真正的多核心並行處理。
我們從四個面向來看這個方案的表現。
多核心並行能力:子行程使用了全部的核心。
回應能力:由於核心管理進程只負責啟動子進程和傳遞訊息,大部分時間裡它都是空閒的,可以處理更多的互動請求。
即使機器的負載壓力很大,我們仍然可以利用作業系統的調度器來提高核心管理進程的優先權。
簡單性:使用了非同步API來隱藏了具體實現的細節,我們可以輕易地將該模組整合到現在專案中,甚至連呼叫程式碼無需作改變。
現在我們來看看,能不能找一個方法,即使負載突然激增,系統的效率也不會異常下降。
當然,最佳目標仍然是,即使壓力激增,系統依然能高效運行,並處理盡量多的請求。
為了幫助實現優秀的方案,compute-cluster 不僅僅只是管理子進程和傳遞訊息,它還管理了其他資訊。
它記錄了目前運行的子進程數,以及每個子進程完成的平均時間。
有了這些記錄,我們可以在子進程開啟之前預測它大概需要多少時間。
據此,再加上使用者設定的參數(max_request_time),我們可以不經過處理,直接就關閉那些可能逾時的請求。
這個特性讓你可以輕易地根據使用者體驗來決定你的程式碼。比方說,「使用者登入的時候不應該等待超過10秒。」這大概等價於將 max_request_time 設定為7秒(需要考慮網路傳輸時間)。
我們在對 Persona 服務進行壓力測試後,得到的結果很讓人滿意。
在壓力極高的情況下,我們依然能為已認證的用戶提供服務,也阻止了一部分未認證的用戶,並顯示了相關的錯誤訊息。