目錄
關於nodejs的介紹網上資料非常多,最近由於在整理一些函數式編程的資料時,多次遇到nodejs有關的內容。所以就打算專門寫一篇文章總結一下nodejs相關知識,包括“說它單線程是什么意思”、“非阻塞又是指什么”以及最重要的是它的“事件輪詢”的實現機制。
本文不介紹nodejs的優缺點(適用場合)、nodejs環境怎樣搭建以及一些nodejs庫的使用等等這些基礎知識。
網上任何一篇關於nodejs的介紹中均會提及到nodejs兩個主要特點:單線程、非阻塞。但是據我所了解到的,大部分介紹一帶而過,並沒有詳細地、系統性地去說明它們到底是怎么回事。下面我依次盡我所能詳細地說一下我對以上兩者的理解。
非阻塞
我們先來看一段.NET中異步編程的代碼:
using(FileStream fs = new FileStream("hello.txt", FileMode.Open))
{
byte[] data = new byte[fs.Length];
fs.BeginRead(data, 0, fs.Length, new AsyncCallback(onRead), null);
Console.WriteLine("the end");
}
如上代碼所示,由於FileStream.BeginRead是一個異步方法,所以不管hello.txt文件有多大,FileStream.BeginRead方法的調用並不會阻塞調用線程,Console.WriteLine方法立馬便可執行。同理,如果在nodejs中所有的方法都是“異步方法”,那么在nodejs中任何方法的調用均不會阻塞調用線程,實質上,nodejs中大部分庫方法確實是這樣的。這就是為什么我們會說nodejs中代碼是非阻塞的。
單線程
對這個概念有誤解的人非常之多,以為nodejs程序中就一個線程,然后有很多人會問:既然只有一個線程,那么怎么並行處理多個任務呢?
其實這里說的單線程並不是指nodejs程序中只有一個線程存在,我個人感覺官方給出“單線程”說法本身就具有誤導性,所以也怪不得大部分初學者。那么“單線程”到底什么意思呢?其實這里的“單線程”指的是我們(開發者)編寫的代碼只能運行在一個線程當中(可以稱之為主線程吧),就像我們在Windows桌面程序開發中一樣,編寫的所有界面代碼均運行在UI線程之中。
那么還是剛才那個問題,所有編寫的代碼均運行在一個線程中,那么怎樣去並行處理任務呢?這個就要想到前面介紹的“異步方法”了,沒錯,雖然開發者編寫的所有代碼均運行在一個線程中,但是我們可以在這個線程中調用異步方法啊,而異步方法內部實現過程當然要采用多線程了。就像下圖:
如上圖所示,nodejs中的單線程指的是圖中的主線程,該主線程中包含一個循環結構,維持整個程序持續運轉。
注:該循環結構也稱之為“泵”結構,是每個系統必備的結構。具體可以參見我之前的一篇博客《動力之源:代碼中的泵》。
因此我們可以說,在nodejs中寫的代碼(包括回調方法)均只運行在一個線程中,但是不代表它只有一個線程。nodejs中許多異步方法在具體的實現時,內部均采用了多線程機制(具體后面會講到)。
如果看過我前面博客的一些讀者可能知道,一個系統(或者說一個程序)中必須至少包含一個大的循環結構(我稱之為“泵”),它是維持系統持續運行的前提。nodejs中一樣包含這樣的結構,我們叫它“事件輪詢”,它存在於主線程中,負責不停地調用開發者編寫的代碼。我們可以查看nodejs官方網站上對nodejs的說明:
我們可以看到,在nodejs中這個“循環”結構對開發者來講是不可見的。
那么開發者編寫的代碼是怎樣通過事件輪詢來得到調用的呢?尤其是一些異步方法中帶的回調函數?看下面一張圖:
如上圖所示,每個異步函數執行結束后,都會在事件隊列中追加一個事件(同時保存一些必要參數)。事件輪詢下一次循環便可取出事件,然后會調用異步方法對應的回調函數(參數)。這樣一來,nodejs便能保證開發者編寫的每行代碼(每個回調)均在主線程中執行。注意這里有一個問題,如果開發者在回調函數中調用了阻塞方法,那么整個事件輪詢就會阻塞,事件隊列中的事件得不到及時處理。正因為這樣,nodejs中的一些庫方法均是異步的,也提倡用戶調用異步方法。
其實看到這里的時候,如果有對Windows編程(尤其對Windows界面編程)比較了解的讀者可能已經聯想到了Windows消息循環。
沒錯,nodejs中的事件輪詢原理跟Windows消息循環的原理類似。開發者編寫的代碼均運行在主線程中,如果你編寫了阻塞代碼,在Windows桌面程序中,由於消息得不到及時處理,界面就會卡死。
咱們再來看一下下面的nodejs代碼:
var fs = require('fs');
fs.readFile('hello.txt', function (err, data) { //異步讀取文件
console.log("read file end");
});
while(1)
{
console.log("call readFile over");
}
如上,雖然我們使用異步方法讀取文件,但是文件讀取完畢后“read file end”永遠不會輸出,也就是說readFile方法的回調函數不會執行。原因很簡單,因為后面的while循環一直沒退出,導致下一次事件輪詢不能開始,所以回調函數不能執行(包括其他所有回調)。事實再次證明,開發者編寫的所有代碼均只能運行在同一線程之中(姑且稱之為主線程吧)。
所謂異步方法,就是調用該方法不會阻塞調用線程,哪怕方法內部要進行耗時操作。你可以理解為方法內部單獨開辟了一個新線程去處理任務,而調用異步方法僅僅是開啟這個新線程。下面的代碼模擬一個異步方法的內部結構(僅僅是模擬,不代表實際):
public void DoSomething(int arg1,AsyncCallback callback)
{
(Action)(delegate()
{
Thread.Sleep(1000*20); //模擬耗時操作
if(callback != null)
{
callback(...); //調用回調函數
}
}).BeginInvoke(null,null);
}
如上代碼所示,調用DoSomething方法不會阻塞調用線程。那么對於每一個異步方法,怎樣去判斷異步操作是否執行完畢呢?這時候必須給異步方法傳遞一個回調函數作為參數,在.NET中,這個回調參數一般是AsyncCallback類型的。如大家所熟知的FileStream.BeginRead/BeginWrite以及Socket.BeginReceive/BeginSend等等均屬於該類方法。
但是,我之所以要提異步方法,就是想讓大家區分nodejs中的異步方法和.NET中異步方法的一個重大區別,雖然兩者內部原理可以理解為一致的,但是在回調函數的調用方式這一點上,兩者有截然不同的方式。
在.NET中,每個異步方法的回調函數均在另外一個線程中執行(非調用線程),而在nodejs中,每個異步方法的回調函數仍然還在調用線程上執行。至於為什么,大家可以看一下前面講事件輪詢的部分,nodejs中每個回調函數均由主線程中的事件輪詢來調用。這樣才能保證在nodejs中,開發者編寫的任何代碼均在同一個線程中運行(所謂的單線程)。
注:不懂調用線程、當前線程是什么意思的同學可以看一下這篇博客:《高屋建瓴:梳理編程約定》。