一個前端工程師眼里的NodeJS

作者: 田永強  來源: InfoQ  發布時間: 2012-09-24 14:15  閱讀: 127342 次  推薦: 42   原文鏈接   [收藏]  

  JavaScript單線程的誤解

  在我接觸JavaScript(無論瀏覽器還是NodeJS)的時間里,總是遇到有朋友有多線程的需求。而在NodeJS方面,有朋友甚至直接說到,NodeJS是單線程的,無法很好的利用多核CPU。

  誠然,在前端的瀏覽器中,由于前端的JavaScript與UI占據同一線程,執行JavaScript確實為UI響應造成了一定程度上的麻煩。但是,除非用到超大的循環語句執行JavaScript,或是用阻塞式的Ajax,或是太過頻繁的定時器執行外,JavaScript并沒有給前端應用帶來明顯的問題,所以也很少有朋友抱怨JavaScript是單線程而不能很好利用多核CPU的問題,因為沒有因此出現性能瓶頸。

  但是,我們可以用Ajax和Web Worker回應這個誤解。當Ajax請求發送之后,除非是同步請求,否則其余的JavaScript代碼會很快被執行到。在Ajax發送完成,直到接收到響應的這段時間里,這個網絡請求并不會阻塞JavaScript的執行,而網絡請求已經發生,這是必然的事。那么,答案就很明顯了,JavaScript確實是執行在單線程上的,但是,整個Web應用執行的宿主(瀏覽器)并非以單線程的方式在執行。而Web Worker的誕生,就是直接為了解決JavaScript與UI占用同一線程造成的UI響應問題的,它能新開一條線程去執行JavaScript。

  同理,NodeJS中的JavaScript也確實是在單線程上執行,但是作為宿主的NodeJS,它本身并非是單線程的,NodeJS在I/O方面又動用到一小部分額外的線程協助實現異步。程序員沒有機會直接創建線程,這也是有的同學想當然的認為NodeJS的單線程無法很好的利用多核CPU的原因,他們甚至會說,難以想象由多人一起協作開發一個單線程的程序。

  NodeJS封裝了內部的異步實現后,導致程序員無法直接操作線程,也就造成所有的業務邏輯運算都會丟到JavaScript的執行線程上,這也就意味著,在高并發請求的時候,I/O的問題是很好的解決了,但是所有的業務邏輯運算積少成多地都運行在JavaScript線程上,形成了一條擁擠的JavaScript運算線程。NodeJS的弱點在這個時候會暴露出來,單線程執行運算形成的瓶頸,拖慢了I/O的效率。這大概可以算得上是密集運算情況下無法很好利用多核CPU的缺點。這條擁擠的JavaScript線程,給I/O形成了性能上限。

  但是,事情又并非絕對的。回到前端瀏覽器中,為了解決線程擁擠的情況,Web Worker應運而生。而同樣,Node也提供了child_process.fork來創建Node的子進程。在一個Node進程就能很好的解決密集I/O的情況下,fork出來的其余Node子進程可以當作常駐服務來解決運算阻塞的問題(將運算分發到多個Node子進程中上去,與Apache創建多個子進程類似)。當然child_process/Web Worker的機制永遠只能解決單臺機器的問題,大的Web應用是不可能一臺服務器就能完成所有的請求服務的。拜NodeJS在I/O上的優勢,跨OS的多Node之間通信的是不算什么問題的。解決NodeJS的運算密集問題的答案其實也是非常簡單的,就是將運算分發到多個CPU上。請參考文章后的multi-node的性能測試,可以看到在多Node進程的情景下,響應請求的速度被大幅度提高。

  在文章的寫作中,Node最新發布的0.5.10版本新增了cluster啟動參數。參數的使用方式如下:

node cluster server.js

  啟動Node的時候,在附加了該參數的情況下,Node會檢測機器上的CPU數量來決定啟動多少進程實例,這些實例會自動共享相同的偵聽端口。詳情請參閱官方文檔。在之前的解決運算密集問題中,工程師需要multi-node這樣的庫或者其他方案去手動分發請求,在cluster參數的支持下,可以釋放掉工程師在解決此問題上的大部分精力。

  事件式編程

  延續上一節的討論。我們知道NodeJS/JavaScript具有異步的特性,從NodeJS的API設計中可以看出來,任何涉及I/O的操作,幾乎都被設計成事件回調的形式,且大多數的類都繼承自EventEmitter。這么做的好處有兩個,一個是充分利用無阻塞I/O的特性,提高性能;另一個好處則是封裝了底層的線程細節,通過事件消息留出業務的關注點給編程者,從而不用關注多線程編程里牽扯到的諸多技術細節。

  從現實的角度而言,事件式編程也更貼合現實。舉一個業務場景為例:家庭主婦在家中準備中餐,她需要完成兩道菜,一道拌黃瓜,一道西紅柿蛋湯。以PHP為例,家庭主婦會先做完拌黃瓜,接著完成西紅柿蛋湯,是以順序/串行執行的。但是現在突然出了一點意外,涼拌黃瓜需要的醬油用光了,需要她兒子出門幫她買醬油回來。那么PHP家庭主婦在叫她兒子出門打醬油的這段時間都是屬于等待狀態的,直到醬油買回來,才會繼續下一道菜的制作。那么,在NodeJS的家庭主婦又會是怎樣一個場景呢,很明顯,在等待兒子打醬油回來的時間里,她可以暫停涼拌黃瓜的制作,而直接進行西紅柿蛋湯的過程,兒子打完醬油回來,繼續完成她的涼拌黃瓜。沒有浪費掉等待的時間。實例偽代碼如下:

var mother = new People();
var child = new People();
child.buySoy(function (soy) {
    mother.cook("cucumber", soy);
});
mother.cook("tomato");

  接下來,將上面這段代碼轉換為基于事件/任務異步模式的代碼:

var proxy = new EventProxy();
var mother = new People();
proxy.bind("cook_cucumber", function (soy) {
    mother.cook("cucumber", soy);
});
proxy.bind("cook_tomato", function () {
    mother.cook("tomato");
});
var child = new People();
child.buySoy(function (soy) {
    proxy.trigger("cucumber", soy);
});
proxy.trigger("tomato");

  代碼量多了很多,但是業務邏輯點都是很清楚的:通過bind方法預定義了cook_cucumber和cook_tomato兩個任務。這里的bind方法可以認為是await的消息式實現,需要第一個參數來標識該任務的名字,流程在執行的過程中產生的消息會觸發這些任務執行。可以看出,事件式編程中,用戶只需要關注它所需要的幾個業務事件點就可以,中間的等待都由底層為你調配好了。這里的代碼只是舉例事件/任務異步模式而用,在簡單的場景中,第一段代碼即可。做NodeJS的編程,會更感覺是在做現實的業務場景設計和任務調度,沒有順序保證,程序結構更像是一個狀態機。

  個人覺得在事件式編程中,程序員需要轉換一下思維,才能接受和發揮好這種異步/無阻塞的優勢。同樣,這種事件式編程帶來的一個問題就在于業務邏輯是松散和碎片式的。這對習慣了順序式,Promise式編程的同學而言,接受它是比較痛苦的事情,而且這種散布的業務邏輯對于非一開始就清楚設計的人而言,閱讀存在相當大的問題。

  我提到事件式編程更貼近于現實生活,是更自然的,所以這種編程風格也導致你的代碼跟你的生活一樣,是一件復雜的事情。幸運的是,自己的生活要自己去面對,對于一個項目而言,并不需要每個人都去設計整個大業務邏輯,對于架構師而言,業務邏輯是明了的,借助事件式編程帶來的業務邏輯松耦合的好處,在設定大框架后,將業務邏輯劃分為適當的粒度,對每一個實現業務點的程序員而言,并沒有這個痛苦存在。二八原則在這個地方非常有效。

  深度嵌套回調問題

  JavaScript/NodeJS對單個異步事件的處理十分容易,但容易出現問題出現的地方是“多個異步事件之間的結果協作”。以NodeJS服務端渲染頁面為例,渲染需要數據,模板,本地化資源文件,這三個部分都是要通過異步來獲取的,原生代碼的寫法會導致嵌套,因為只有這樣才能保證渲染的時候數據,模板,本地化資源都已經獲取到了。但問題是,這三個步驟之間實際是無耦合的,卻因為原生代碼沒有promise的機制,將可以并行執行(充分利用無阻塞I/O)的步驟,變成串行執行的過程,直接降低了性能。代碼如下:

var render = function (template, data) {
    _.template(template, data);
};
$.get("template", function (template) { // something 
    $.get("data", function (data) { // something 
        $.get("l10n", function (l10n) { // something 
            render(template, data);
        });
    });
});

  面對這樣的代碼,許多工程師都表示不爽。這個弱點也形成了對NodeJS推廣的一個不大不小的障礙。對于追求性能和維護性的同學,肯定不滿足于以上的做法。本人對于JavaScript的事件和回調都略有偏愛,并且認為事件,回調,并行,松耦合是可以達成一致的。以下一段代碼是用EventProxy實現的:

var proxy = new EventProxy();
var render = function (template, data, l10n) {
    _.template(template, data);
};
proxy.assign("template", "data", "l10n", render);
$.get("template", function (template) { // something 
    proxy.trigger("template", template);
});
$.get("data", function (data) { // something 
    proxy.trigger("data", data);
});
$.get("l10n", function (l10n) { // something 
    proxy.trigger("l10n", l10n);
});

  代碼量看起來比原生實現略多,但是從邏輯而言十分清晰。模板、數據、本地化資源并行獲取,性能上的提高不言而喻,assign方法充分利用了事件機制來保證最終結果的正確性。在事件,回調,并行,松耦合幾個點上都達到期望的要求。

  關于更多EventProxy的細節可參考其官方頁面

  深度回調問題的延伸

  EventProxy解決深度回調的方式完全基于事件機制,這需要建立在事件式編程的認同上,那么必然也存在對事件式編程不認同的同學,而且習慣順序式,promise式,向其推廣bind/trigger模式實在難以被他們接受。JscexStreamline.js是目前比較成熟的同步式編程的解決方案。可以通過同步式的思維來進行編程,最終執行的代碼是通過編譯后的目標代碼,以此通過工具來協助用戶轉變思維。

  結語

  對于優秀的東西,我們不能因為其表面的瑕疵而棄之不用,總會有折衷的方案來滿足需求。NodeJS在實時性方面的功效有目共睹,即便會有一些明顯的缺點,但是隨著一些解決方案的出現,相信沒有什么可以擋住其前進的腳步。

  附錄(多核環境下的并發測試)

  服務器環境:

  • 網絡環境:內網
  • 壓力測試服務器:
  • 服務器系統:Linux 2.6.18
  • 服務器配置:Intel(R) Xeon(TM) CPU 3.40GHz 4 CPUS
  • 內存:6GB
  • NodeJS版本: v0.4.12

  客戶端測試環境:

  • 發包工具:apache 2.2.19自帶的ab測試工具
  • 服務器系統:Linux 2.6.18
  • 服務器配置:Pentium(R) Dual-Core CPU E5800 @ 3.20GHz 2CPUS
  • 內存:1GB

  單線程Node代碼:

var http = require('http');
var server = http.createServer(function (request, response) {
    var j = 0;
    for (var i = 0; i & lt; 100000; i++) {
        j += 2 / 3;
    }
    response.end(j + '');
});
server.listen(8881);
console.log('Server running at http://10.1.10.150:8881/');

  四進程Node代碼:

var http = require('http');
var server = http.createServer(function (request, response) {
    var j = 0;
    for (var i = 0; i & lt; 100000; i++) {
        j += 2 / 3;
    }
    response.end(j + '');
});
var nodes = require("./lib/multi-node").listen({
    port: 8883,
    nodes: 4
}, server);
console.log('Server running at http://10.1.10.150:8883/');

  這里簡單介紹一下multi-node這個插件,這個插件就是利用require("child_process").spawn()方法來創建多個子線程。由于浮點計算和字符串拼接都是比較耗CPU的運算,所以這里我們循環10W次,每次對j加上0.66666。最后比較一下,開多子進程node到底比單進程node在CPU密集運算上快多少。

  以下是測試結果:

Comm.

500/30

500/30

1000/30

1000/30

3000/30

3000/30

Type

單進程

多子進程

單進程

多子進程

單進程

多子進程

RPS

2595

5597

2540

5509

2571

5560

TPQ

0.38

0.18

0.39

0.19

0.39

0.18

80% REQ

72

65

102

85

157

142

Fail

0

0

0

0

0

0

  說明:

  • 單進程:只開一個node.js進程。
  • 多子進程:開一個node.js進程,并且開3個它的子進程。
  • 3000/30:代表命令 ./ab -c 3000 -t 30 http://10.1.10.150:8888/。3000個客戶端,最多發30秒,最多發5W個請求。
  • RPS:代表每秒處理請求數,并發的主要指標。
  • TPQ:每個請求處理的時間,單位毫秒。
  • Fail:代表平均處理失敗請求個數。
  • 80% Req:代表80%的請求在多少毫秒內返回。

  從結果及圖1~3上看:開多個子進程可以顯著緩解node.js的CPU利用率不足的情況,提高node.js的CPU密集計算能力。

  圖1:單個進程的node.js在壓力測試下的情況,無法充分利用4核CPU的服務器性能。

  圖2:多進程node,可以充分利用4核CPU榨干服務器的性能。

  圖3:多子進程截圖,可以看到一共跑了4個進程。

  關于作者

  田永強,前端工程師,就職于SAP,從事Mobile Web App方面的研發工作,對NodeJS持有高度的熱情,寄望打通前端JavaScript與NodeJS的隔閡,將NodeJS引薦給更多的前端工程師。

42
2
 
 
 

文章列表

arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()