一個前端工程師眼里的NodeJS
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模式實在難以被他們接受。Jscex和Streamline.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引薦給更多的前端工程師。