2012年3月6日 星期二

探討 Node.js 的非同步機制

Standard
Node.js 標榜著事件驅動(Event Drive)的設計,也因為如此,它在網站應用程式的領域上,通常強調有最快速的即時反應。其原理是利用 libev 去實作事件輪詢,不斷的檢查是否有事件需要被處理,一旦發現有事件在待命,就去執行並觸發相應的 Handler。但無可避免的,總會有程式和工作需要佔用大量的 CPU 時間,因此獨佔目前的事件處理程序,造成整個程式被單一事件卡死。這樣的問題,有機會讓原本期望的即時反應機制崩潰。

同樣的問題,也出現在其它 non-blocking 設計的 Framework。Facebook 所開發,也紅過一陣子的 Tornado Web Framework,針對這個問題,就提供了一個 Decorator  -『@tornado.web.asynchronous』,這可以讓 Handler 處於非同步的模式下執行,意味著該段程式可以先丟到背景,而不會讓整個程式為了等待該 Handler 結束,而耗時太久或卡死。

相同的,Node.js 也提供擁有類似的設計,只不過對於語言的使用者來說,概念和用法上不太一樣,因為 Node.js 所提供的 API 目標並不只是單純處理 Web Server 的應用,更像是低階的工作排程器的控制,如作業系統上的 yield() 或 sched_yield()。

我們可以很快的透過一個簡單的例子,去理解怎麼使用他,假設我們開一個檔案讀資料,一般邏輯上的做法(這裡所用的 file 物件是假的,在真實的 Node.js API 中並不存在,只是為了說明方便):
file.open();
while(true) {
    result = file.read();
    if (!result)
        break;

    /* Do something... */
};

console.log('blah blah blah...');
若是這個檔案很大,需要花三分鐘才能讀完,那這個 while 迴圈肯定會鎖死,等讀完後才顯示『blah blah blah...』字樣。想想看,若是這樣的程式被放在 Web Framework 的 Handler 中,肯定會因為這一個使用者,讓其他人三分鐘之內,都無法使用這個網站服務。而實際上,這樣的問題最常出現於檔案上傳的工作上。

若是我們可以用process.nextTick()來改寫,則情況會大不同:
function readLoop() {
    result = file.read();
    if (!result)
        return;

    /* Do something... */
    process.nextTick(readLoop);
}

file.open();
process.nextTick(readLoop);
console.log('blah blah blah...');
結果是『blah blah blah...』字樣會立即出現,檔案讀取工作都會被分段排在每次的排程,這讓事件引擎得以喘口氣,處理其他的工作和事件,整個服務也不會因為單一工作而受阻中斷。

或許你已經發現到了,這樣的做法,其實與過去在瀏覽器製作動畫效果的方法雷同,運用 setTimeout() 去重覆一個會佔用大量時間的工作。為了便於理解,你也可以將它想像成 setTimeout(fn, 0) 的取代,只不過 process.nextTick() 的執行上會更加有效率,這點在 Node.js 的官方文件裡也有特別說明。

如果你有興趣要瞭解 process.nextTick() 是怎麼被實現的,就必需從Node.js 的設計根本面來著手。前面提到,Node.js事件驅動(Event Drive)主要實做於 libev 的基礎之上,Node.js 實作了一個佇列(Queue) 存放事件,而所有的事件觸發,都是由一個輪詢事件的引擎所驅動 。process.nextTick() 就是讓我們可以把程式事件放到佇列(Queue),使其在下一次事件輪詢時被驅動。

你可以參考 Node.js 原始程式碼中的 src/node.js,得到此 API 的實作:
    process._tickCallback = function() {
      var l = nextTickQueue.length;
      if (l === 0) return;

      var q = nextTickQueue;
      nextTickQueue = [];

      try {
        for (var i = 0; i < l; i++) q[i]();
      }
      catch (e) {
        if (i + 1 < l) {<
          nextTickQueue = q.slice(i + 1).concat(nextTickQueue);
        }
        if (nextTickQueue.length) {
          process._needTickCallback();
        }
        throw e; // process.nextTick error, or 'error' event on first tick
      }
    };
    process.nextTick = function(callback) {
      nextTickQueue.push(callback);
      process._needTickCallback();
    };
更多細節事件引擎的細節,讀者可以自行參考 Node.js 的原始程式,就不在本文討論了。