Node.js初體驗

發表於2013-03-17

來源:張澤鵬的部落格(@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開發的過程裡,經常會有與下面類似的程式碼:

程式碼塊A先處理一些任務;程式碼塊B傳送查詢語句到資料庫,等待返回資料集;程式碼塊C處理返回結果;程式碼塊D繼續做其他事情。執行時序圖如下:

Node.js初體驗

容易看出,在等待程式碼塊B時,整個程式都暫停了,中間有一大段空閒時間沒有處理任何任務。從依賴關係上說,程式碼塊C必須在程式碼塊B成功執行後才能執行;但程式碼塊D對前面的B、C並沒有依賴關係。因此,如果在等待期間先執行程式碼塊D,直到程式碼塊B執行完畢再觸發程式碼塊C。如下圖所示:

Node.js初體驗

假設每個程式碼塊所需的執行時間是5秒,那第一種方案需要20秒,而第二種只需要15秒。Node.js要做的事情就是使用第二種方案取代第一種方案以獲得效能的提升。

回撥函式佇列

情理之中意料之外,Node.js實現的方式是單程式且單執行緒。它內部維護著一個回撥函式佇列,遵循先到先處理的原則逐個執行。讓我聯想到批處理作業系統,任務一個接一個地執行,沒有搶佔並享有所有系統資源。Node.js回撥機制所做的事情就是把相應的程式碼塊塞到隊尾。

比如上一節的例子中的方法二,執行過程就變成:程式碼塊A被塞到佇列中;資料庫查詢語句註冊了一個事件並繫結程式碼塊C為回撥函式;程式碼塊D被塞到隊尾;此時程式碼塊B執行完成並觸發事件,把程式碼塊C塞到隊尾。因此,依次執行的是A、D、C(注:B是資料庫伺服器上的查詢操作,並不是Node.js中執行的程式碼),期間並無間歇。

考慮下面的程式碼,就遵循上述的程式碼模式:

  1. 程式碼塊A:請求計數以及記錄請求開始處理的時間(不是到達時間)。
  2. 程式碼塊B:此處用setTimeout做了5秒延遲,模擬外部程式處理五秒鐘。
  3. 程式碼塊C:記錄回撥函式被呼叫的時間,睡5秒來模擬伺服器運算,並記錄結束時間。
  4. 程式碼塊D:記錄響應的時間(即使用者收到回饋的時間)。

在我的機器上執行結果如下:

和預期的一樣:請求在19:12:35時開始處理,並且沒有被阻塞,而是在同一時間就返回了;5秒後回撥函式開始被處理;又過了5秒回撥函式執行完畢,整個過程結束。

我們來數數上面這段程式碼總共有幾個顯眼的回撥函式:

  1. 整段程式碼/檔案是一個回撥函式,在程式啟動時被塞到佇列中並立即執行了;
  2. createServer 中註冊的回撥函式,在收到使用者請求後被觸發(塞到佇列,不一定馬上執行);
  3. setTimeout 中註冊的回撥函式,在延遲5後才被塞到佇列。

單執行緒的問題

Node.js採用單程式+單執行緒的其中一個原因是避免系統頻繁開闢執行緒帶來的開銷。網路上說開啟一個執行緒要使用2M的記憶體(有這麼多?),我沒去驗證過具體數值,但至少是有一些開銷的,當請求量大是的確會成為瓶頸。上節提到Node.js的處理任務的方式類似批處理作業系統,因此它在規避執行緒開銷的同時也完全繼承了批處理方式的缺陷——互動不友好。我指的互動是後面的請求會被回撥函式佇列中前面的任務阻塞住,從接受到回饋耗費很長的時間等待。

用下面程式碼分別在第0秒、第1秒、第7秒傳送一個請求,並記錄每個請求從發出到收到回饋的時間:

執行結果如下:

  1. 19:16:36 傳送第一個請求,幾乎馬上收到回饋;
  2. 等待1秒後傳送第二個請求,同樣馬上收到回饋;
  3. 繼續等待6秒傳送第三個請求,耗時8秒後猜收到回饋!

相信第三次請求的使用者會極其不滿,來看看Node.js執行時的快照:

下表中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非同步請求儲存這條資料。