來源:張澤鵬的部落格(@redraiment)
又到週五晚上自由時間,^_^。今天看了一下 Node.js。
伺服器端 JS 情緣
在校期間我學會了JavaScript和Java,當時我就在考慮JS有沒有類似JSP一樣的伺服器端程式,名字應該是JSSP(JavaScript Server Page),可以在 HTML 中嵌入 JS。Google了一圈發現IIS支援用JScript代替VBScript做ASP開發,另外SourceForge上真有個叫JSSP的專案,以及今天的主角Node.js。當時的Node.js剛起步,首頁背景還是黑乎乎的(不曉得其他童鞋是否也有印象)。經過一圈比較,我最終選擇使用Rhino——一個純Java實現的JS引擎,它吸引我的地方是能直接呼叫Java類庫。
Node.js
最近關注Node.js人變多了。在長期與一堆厚重的Java框架、類庫為伍之後,我也想看看外面的世界。Node.js最為人所津津樂道的就是非同步加回撥機制以及良好的效能。我想知道它和我熟悉的Java有何不同。
Node.js 要解決的問題
在使用Java開發的過程裡,經常會有與下面類似的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// block A // do something // block B // on Database ResultSet rs = dbo.executeQuery("Query Statement"); while (rs.next()) { // block C // parse the result } // block D // do something |
程式碼塊A先處理一些任務;程式碼塊B傳送查詢語句到資料庫,等待返回資料集;程式碼塊C處理返回結果;程式碼塊D繼續做其他事情。執行時序圖如下:
容易看出,在等待程式碼塊B時,整個程式都暫停了,中間有一大段空閒時間沒有處理任何任務。從依賴關係上說,程式碼塊C必須在程式碼塊B成功執行後才能執行;但程式碼塊D對前面的B、C並沒有依賴關係。因此,如果在等待期間先執行程式碼塊D,直到程式碼塊B執行完畢再觸發程式碼塊C。如下圖所示:
假設每個程式碼塊所需的執行時間是5秒,那第一種方案需要20秒,而第二種只需要15秒。Node.js要做的事情就是使用第二種方案取代第一種方案以獲得效能的提升。
回撥函式佇列
情理之中意料之外,Node.js實現的方式是單程式且單執行緒。它內部維護著一個回撥函式佇列,遵循先到先處理的原則逐個執行。讓我聯想到批處理作業系統,任務一個接一個地執行,沒有搶佔並享有所有系統資源。Node.js回撥機制所做的事情就是把相應的程式碼塊塞到隊尾。
比如上一節的例子中的方法二,執行過程就變成:程式碼塊A被塞到佇列中;資料庫查詢語句註冊了一個事件並繫結程式碼塊C為回撥函式;程式碼塊D被塞到隊尾;此時程式碼塊B執行完成並觸發事件,把程式碼塊C塞到隊尾。因此,依次執行的是A、D、C(注:B是資料庫伺服器上的查詢操作,並不是Node.js中執行的程式碼),期間並無間歇。
考慮下面的程式碼,就遵循上述的程式碼模式:
- 程式碼塊A:請求計數以及記錄請求開始處理的時間(不是到達時間)。
- 程式碼塊B:此處用setTimeout做了5秒延遲,模擬外部程式處理五秒鐘。
- 程式碼塊C:記錄回撥函式被呼叫的時間,睡5秒來模擬伺服器運算,並記錄結束時間。
- 程式碼塊D:記錄響應的時間(即使用者收到回饋的時間)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
var http = require('http'); var count = 0; http.createServer(function(request, response) { // block A count++; var id = count; var start = new Date(); var reply; // block B setTimeout(function() { // block C var called = new Date(); var end; do { end = new Date(); } while (end.getTime() - called.getTime() < 5000); console.log(id + ' start @ ' + start); console.log(id + ' reply @ ' + reply); console.log(id + ' called @ ' + called); console.log(id + ' end @ ' + end); }, 5000); // block D response.writeHead(200, {'Content-Type': 'text/html'}); response.end(); reply = new Date(); }).listen(80); |
在我的機器上執行結果如下:
1 2 3 4 5 |
λ sudo node main.js 1 start @ Fri Sep 21 2012 19:12:35 GMT+0800 (CST) 1 reply @ Fri Sep 21 2012 19:12:35 GMT+0800 (CST) 1 called @ Fri Sep 21 2012 19:12:40 GMT+0800 (CST) 1 end @ Fri Sep 21 2012 19:12:45 GMT+0800 (CST) |
和預期的一樣:請求在19:12:35時開始處理,並且沒有被阻塞,而是在同一時間就返回了;5秒後回撥函式開始被處理;又過了5秒回撥函式執行完畢,整個過程結束。
我們來數數上面這段程式碼總共有幾個顯眼的回撥函式:
- 整段程式碼/檔案是一個回撥函式,在程式啟動時被塞到佇列中並立即執行了;
- createServer 中註冊的回撥函式,在收到使用者請求後被觸發(塞到佇列,不一定馬上執行);
- setTimeout 中註冊的回撥函式,在延遲5後才被塞到佇列。
單執行緒的問題
Node.js採用單程式+單執行緒的其中一個原因是避免系統頻繁開闢執行緒帶來的開銷。網路上說開啟一個執行緒要使用2M的記憶體(有這麼多?),我沒去驗證過具體數值,但至少是有一些開銷的,當請求量大是的確會成為瓶頸。上節提到Node.js的處理任務的方式類似批處理作業系統,因此它在規避執行緒開銷的同時也完全繼承了批處理方式的缺陷——互動不友好。我指的互動是後面的請求會被回撥函式佇列中前面的任務阻塞住,從接受到回饋耗費很長的時間等待。
用下面程式碼分別在第0秒、第1秒、第7秒傳送一個請求,並記錄每個請求從發出到收到回饋的時間:
1 2 3 4 5 6 7 8 9 10 11 |
#!/bin/bash date time curl http://localhost sleep 1 date time curl http://localhost sleep 6 date time curl http://localhost date |
執行結果如下:
- 19:16:36 傳送第一個請求,幾乎馬上收到回饋;
- 等待1秒後傳送第二個請求,同樣馬上收到回饋;
- 繼續等待6秒傳送第三個請求,耗時8秒後猜收到回饋!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
$ ./submit.sh 2012年 09月 21日 星期五 19:16:36 CST real 0m0.018s user 0m0.004s sys 0m0.008s 2012年 09月 21日 星期五 19:16:37 CST real 0m0.015s user 0m0.012s sys 0m0.000s 2012年 09月 21日 星期五 19:16:43 CST real 0m7.982s user 0m0.000s sys 0m0.004s 2012年 09月 21日 星期五 19:16:51 CST |
相信第三次請求的使用者會極其不滿,來看看Node.js執行時的快照:
1 2 3 4 5 6 7 8 9 10 11 12 |
1 start @ Fri Sep 21 2012 19:16:36 GMT+0800 (CST) 1 reply @ Fri Sep 21 2012 19:16:36 GMT+0800 (CST) 1 called @ Fri Sep 21 2012 19:16:41 GMT+0800 (CST) 1 end @ Fri Sep 21 2012 19:16:46 GMT+0800 (CST) 2 start @ Fri Sep 21 2012 19:16:37 GMT+0800 (CST) 2 reply @ Fri Sep 21 2012 19:16:37 GMT+0800 (CST) 2 called @ Fri Sep 21 2012 19:16:46 GMT+0800 (CST) 2 end @ Fri Sep 21 2012 19:16:51 GMT+0800 (CST) 3 start @ Fri Sep 21 2012 19:16:51 GMT+0800 (CST) 3 reply @ Fri Sep 21 2012 19:16:51 GMT+0800 (CST) 3 called @ Fri Sep 21 2012 19:16:56 GMT+0800 (CST) 3 end @ Fri Sep 21 2012 19:17:01 GMT+0800 (CST) |
下表中A1表示第一次請求中的程式碼塊A,其他以此類推。假定程式碼塊A、B、D都是瞬間完成,只有C耗時5秒。從表中可知,第三次請求是在第15鍾才開始被處理。根據測試指令碼的輸出可知,第三次請求其實在第7秒就已經發出了,但由於是那時佇列中還有C1、C2在處理,因此等待了8秒鐘!假設佇列的平均長度是100,那每個請求平均的等待時間就是 (100 / 4 – 1) * (A+B+C+D),即要等前面24個請求處理完。這還僅僅是請求得到回饋的時間,該請求對應回撥函式被執行的時間還要更久。
當前時間 | 當前佇列 |
0 | A1 B1 D1 |
1 | A2 B2 D1 |
5 | C1 |
6 | C1 C2 |
7 | C1 C2 A3 B3 D3 |
10 | C2 A3 B3 D3 |
15 | A3 B3 D3 |
20 | C3 |
25 |
適用場景
通過上面的研究,我覺得Node.js並不適合需要與使用者實時互動的系統;它適合集中處理使用者發來的大規模“指令”,即不需要及時看到結果的請求。比如微博系統,使用者發表一條微博,可能需要在伺服器上排隊1分鐘才能最終儲存到資料庫。在這一分鐘裡,使用者更多地是看看別人發表的微博,並不十分迫切地想看到自己那條微博。如果希望有更好的體驗,其實可以用DOM直接把使用者發表的微博先更新到當前頁面,同時使用Ajax非同步請求儲存這條資料。