Node.js 異步編程基礎理解

參考地址:《深入理解node.js異步編程:基礎篇》


一、概述

目前開源社區最火熱的技術當屬 Node.js 莫屬了,作爲使用 Javascript 爲主要開發語言的服務器端編程技術和平臺,一開始就註定會引人矚目。 當然能夠吸引衆人的目光,肯定不是三教九流之輩,必然擁有獨特的優勢和魅力,才能引起羣猿追逐。其中當屬異步 IO 和事件編程模型,本文據 Node.js 的異步 IO 和事件編程做深入分析。

1. 什麼是異步

同步和異步是一個比較早的概念,大抵在操作系統發明時應該就出現了。舉一個最簡單的生活中的例子,比如發短信的情況會比較好說明他們的區別:
同步:正在處於苦逼工作狀態中的我,但狗屎運的交到了女朋友並正處於處於熱戀期,因此發送短信給她詢問那個餐廳吃飯,急不可耐的看着手機等待短信回覆,收到信息看完是否加班或者下班;
異步:正處於公司運營決策關鍵工作狀態中的你,不可以被打斷太久,隨便發送了一條詢問老婆什麼時候做好晚飯然後吃飯的短信後立馬返回工作,一邊工作一邊等待短信回覆通知,根據通知決定是否再工作和下班。

由此可以看出,同步和異步的特點是:

  • 至少在兩個對象之間需要協作(男朋友和女朋友,老公和老婆);
  • 兩個對象都需要處理一系列的事情(工作和吃飯)。

另一個類似的關於CPU計算和磁盤操作編的例子:
同步:CPU需要計算10個數據,每計算一個結果後,將其寫入磁盤,等待寫入成功後,再計算下一個數據,直到完成。
異步:CPU需要計算10個數據,每計算一個結果後,將其寫入磁盤,不等待寫入成功與否的結果,立刻返回繼續計算下一個數據,計算過程中可以收到之前寫入是否成功的通知,直到完成。

2. 爲什麼需要異步

知其然,還要知其所以然,讀者可能會問,爲什麼存在異步?根據上面發短信和磁盤操作的例子,答案很明顯,爲了提高辦事的效率,CPU計算速度和磁盤的讀寫速度差太遠了,磁盤供不應求,因此有了計算機的存儲系統的分層設計,平衡了效率和成本。可以說懶惰推動人類的進步,任何可以降低花費時間而達到同等功效的方法肯定會被優先採用。發送短信時等待對方回覆的時間純粹的浪費掉了,CPU寫入磁盤等待返回的結果的等待時間也被無情的消耗了,這是一個講究效率的時代完全不能忍受的,因此讓員工一直處於忙碌狀態,最大限度的榨取員工價值是老闆追求的,讓CPU和磁盤都不停的滿負荷處理事務也是效率需要的。因此,異步處理出現了。

二、Node.js 異步 IO 與事件

初次接觸Node.js,恐怕任何人都會被先先灌輸的第一條Node.js就與衆不同的地方:異步IO和事件驅動。毫無疑問,這確實是Node.js最令人津津樂道的特色之處,也是本文重點分析的地方。

1. Node.js 異步機制

由於異步的高效性,node.js 設計之初就考慮做爲一個高效的 web 服務器,作者理所當然地使用了異步機制,並貫穿於整個 node.js 的編程模型中,新手在使用 node.js 編程時,往往會羈絆於由於其他編程語言的習慣,比如 C/C++ ,覺得無所適從。我們可以從以下一段簡單的睡眠程序代碼窺視出他們的區別:
下面是摘自《linux 程序設計》打印10個時間的C代碼:

#include <time.h>
#include <stdio.h>
#include <unistd.h>
int main()
{
    int i;
    time_t the_time;
    for(i = 1; i <= 10; i++) {
        the_time = time((time_t *)0);
        printf("The time is %ld\n", the_time);
        sleep(2);
    }
    exit(0);
}

編譯後打印結果爲:

The time is 1396492137
The time is 1396492139
The time is 1396492141
The time is 1396492143
The time is 1396492145
The time is 1396492147
The time is 1396492149
The time is 1396492151
The time is 1396492153
The time is 1396492155

從C語言的打印結果可以發現,是隔2秒打印一次,按照C程序該有的邏輯,代碼逐行執行。
以下 Node.js 代碼本意如同上述C代碼,使用目的隔2秒打印一次時間,共打印10條(初次從 C/C++ 轉來接觸 Node.js 的程序員可能會寫出下面的代碼):

function test() {
    for (var i = 0; i < 10; i++) {
        console.log(new Date);
        setTimeout(function(){}, 2000); //睡眠2秒,然後再進行一下次for循環打印
    }
};
test();

打印結果如下:

Tue Apr 01 2014 14:53:22 GMT+0800 (中國標準時間)
Tue Apr 01 2014 14:53:22 GMT+0800 (中國標準時間)
Tue Apr 01 2014 14:53:22 GMT+0800 (中國標準時間)
Tue Apr 01 2014 14:53:22 GMT+0800 (中國標準時間)
Tue Apr 01 2014 14:53:22 GMT+0800 (中國標準時間)
Tue Apr 01 2014 14:53:22 GMT+0800 (中國標準時間)
Tue Apr 01 2014 14:53:22 GMT+0800 (中國標準時間)
Tue Apr 01 2014 14:53:22 GMT+0800 (中國標準時間)
Tue Apr 01 2014 14:53:22 GMT+0800 (中國標準時間)
Tue Apr 01 2014 14:53:22 GMT+0800 (中國標準時間)

觀察結果發現都是在14:53:22同一個時間點打印的,根本就沒有睡眠2秒後再執行下一輪循環打印!
這是爲什麼?從官方的文檔我們看出 setTimeout 是第二個參數表示逝去時間之後在執行第一個參數表示的 callback 函數,因此我們可以分析, 由於 Node.js 的異步機制,setTimeout 每個 for 循環到此之後,都註冊了一個2秒後執行的回調函數然後立即返回馬上執行 console.log(new Date),導致了所有打印的時間都是同一個點,因此我們修改for循環的代碼如下:

for (var i = 0; i < 10; i++) {
    setTimeout(function(){
        console.log(new Date);
    }, 2000);   
}

執行結果如下所示:

Thu Apr 03 2014 09:30:35 GMT+0800 (中國標準時間)
Thu Apr 03 2014 09:30:35 GMT+0800 (中國標準時間)
Thu Apr 03 2014 09:30:35 GMT+0800 (中國標準時間)
Thu Apr 03 2014 09:30:35 GMT+0800 (中國標準時間)
Thu Apr 03 2014 09:30:35 GMT+0800 (中國標準時間)
Thu Apr 03 2014 09:30:35 GMT+0800 (中國標準時間)
Thu Apr 03 2014 09:30:35 GMT+0800 (中國標準時間)
Thu Apr 03 2014 09:30:35 GMT+0800 (中國標準時間)
Thu Apr 03 2014 09:30:35 GMT+0800 (中國標準時間)
Thu Apr 03 2014 09:30:35 GMT+0800 (中國標準時間)

神奇,仍然是同一個時間點,見鬼!
冷靜下來分析,時刻考慮異步,for 循環裏每次 setTimeout 註冊了2秒之後執行的一個打印時間的回調函數,然後立即返回,再執行 setTimeout,如此反覆直到 for 循環結束,因爲執行速度太快,導致同一個時間點註冊了10個2秒後執行的回調函數,因此導致了2秒後所有回調函數的立即執行。
我們在 for 循環之前添加 console.log(“before FOR: ” + new Date) 和之後 console.log(“after FOR: ” + new Date),來驗證我們的推測,代碼如下:

console.log("before FOR: " + new Date);
for (var i = 0; i < 10; i++) {
    setTimeout(function(){
        console.log(new Date);
    }, 2000);   
}
console.log("after FOR: " + new Date);

打印結果如下(後面省略8條相同的打印行):

before FOR: Thu Apr 03 2014 09:42:43 GMT+0800 (中國標準時間)
after FOR: Thu Apr 03 2014 09:42:43 GMT+0800 (中國標準時間)
Thu Apr 03 2014 09:42:45 GMT+0800 (中國標準時間)
Thu Apr 03 2014 09:42:45 GMT+0800 (中國標準時間) …… (省略與上一行8條相同的打印行)

由此可以窺視出Node.js異步機制的端倪了,在for循環中的代碼於其後的代碼幾乎在一個單位秒內完成,而定時器中的回調函數則按要求的2秒之後執行,也是同一秒內執行完畢。
那麼如何實現最初C語言每隔2秒打印一個系統時間的需求函數呢,作者實現瞭如下一個 wsleep 函數,放在 for 循環中,可以達到該目的:

function wsleep(milliSecond) {
    var startTime = new Date().getTime();
    while(new Date().getTime() <= milliSecond + startTime) {
    }
}

但這個寫法不建議,會阻塞CPU,令 CPU 在 20s 內都不能做別的事情,推薦用下面寫法:

for (var i = 0; i < 10; i++) {
    setTimeout(function () {
        console.log(new Date());
    }, 2000*(i+1));
}

2. Node.js事件編程

事件編程並不是一個新的概念,做過界面 UI 編程的程序猿們可以覺得事件再熟悉不過了,特別是客戶端開發和 web 開發的感觸頗深吧,如 Android、ios、或是 javascript 前端編程的工程師們,一個按鈕、一個列表項、一個長按操作等等,每次按下都會由操作系統或者瀏覽器產生一個事件,你需要做的工作就是編寫和註冊這個事件的回調函數(可能各自領域內不稱爲回調函數,但是從操作系統的角度考慮其實就是一個回調函數),當這個事件發生時,執行你的回調函數。
Node.js 與衆不同的是,它基因裏就是由事件和異步組成的。請看用於生產環境中的真實項目代碼的一個片段(略去了一些不相關的代碼),我加上一段關於事件信息的註釋,讓讀者更清晰:

// 監聽socket連接事件
self.sio.sockets.on('connection', function(socket) {        
    var addr = socket.handshake.address;
    var limiter = new RateLimiter(constant.RL_MAXREQRATELIMIT, constant.RL_RATELIMITUNIT, true);
    var connect = new Connection(socket);
    then(function(defer) {  
        if (ipLimit) {
        // 結果回調處理事件
            throttle.throttleHandle(connect, null, defer);  
        } else {
            // 發送處理結果事件
            defer(null);    
        }
    // 收到處理結果事件
    }).all(function(defer) {    
        // 監聽數據傳輸事件
        socket.on('message', function(data) {   
                    cloudKeyMain(connect, 1, data, cloudKeyApi);
        });
    });
    // 監聽socket離線事件
    socket.on("disconnect", function(data) {    
        var currentSockClient = connect.client;
        if (currentSockClient) {
            // 發送客戶端離線事件
            currentSockClient.signalOffline();  
        }

    });
});

從上面的代碼,我們可以看出 Node.js 無所不在的事件機制,事件機制讓我們專注與代碼業務的處理流程,提高了軟件開發的效率,降低了代碼之間的耦合,讓人不被瑣事纏繞,編程更有趣。如何開始一個簡單的 Node.js 事件編程呢?答案是使用 Node.js 的 javascript API 核心模塊 events 的 events.EventEmitter 類即可完成,下面以一個 QQ 的在線和離線來說明,事件機制的使用主要包括3個方面的內容:

  • 繼承events.EventEmitter事件類,主要是屏蔽事件機制的實現(其實原理很簡單),讓我們直接使用;
  • 事件的註冊;
  • 事件的發佈;
var events = require('events');
var util = require('util');
    function MyQQ() {
    events.EventEmitter.call(this);
    //……
}
util.inherits(MyQQ, events.EventEmitter);

OK,上述代碼就完成了事件機制的添加,此時,我們的工作爲 QQ 添加事件註冊函數進行事件的註冊,事件註冊主要是使用 EventEmitter 的 on() 完成,因爲我們繼承了 EventEmitter,可以直接使用 on 函數,我們在 on 函數的第二個參數 callback 函數中自定義處理業務,並註冊自己的上線事件(類似於 Qt 的信號槽機制)。以下是一個 QQ 上線時簡單的處理業務:

function onlineHandle(QQNumber) {
    //獲取和QQNumber的聯繫人列表
    //獲取離線消息
    //……
}
var myQQ = new MyQQ();
myQQ.on(“onLine”, onlineHandle);

上述代碼完成了事件的處理,下面輪到在什麼時候發佈這個事件。下述的一個業務場景中可能是需要發佈該事件的,發佈事件用emit()函數:

function main() {
    //連接服務器
    //檢測登錄狀態
    //登錄服務器成功後發佈事件
    myQQ.emit(“onLine”,123655245);
}

上述 myQQ.emit() 函數執行後發佈了 onLine 事件後,會立即執行 onlineHandle() 函數,處理我們註冊的業務邏輯,需要注意的是,事件發佈函數 emit 第二個參數後的參數個數需要和我們註冊時的處理函數參數個數相同並且順序一致才能正確處理,爲什麼有這樣的要求?這需要從 Node.js 事件的原理說起。基本上所有的事件機制都是用設計模式中觀察者模式實現,觀察者模式網絡資料一大堆,如何想要深入瞭解的話可以網絡搜索或者閱讀權威書籍,可以參考《設計模式:可複用面向對象軟件的基礎》和《Head First 設計模式》。

發佈了60 篇原創文章 · 獲贊 259 · 訪問量 47萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章