1. 程式人生 > >深入剖析Nodejs的非同步IO

深入剖析Nodejs的非同步IO

前言:Nodejs最賴以自豪的優勢莫過於“單執行緒實現非同步IO”了,也許你仍然丈二和尚摸不著頭腦,Nodejs自我標榜是單執行緒,還能實現非同步IO操作,這兩者難道不是相互矛盾的麼?葫蘆裡到底藏著什麼藥? 且聽我娓娓道來……

一、首先,看看Nodejs的架構圖

這裡寫圖片描述

這裡寫圖片描述

Nodejs結構大體分為三個部分:

1)Node.js標準庫:這部分由javascript編寫。也就是平時我們經常require的各個模組,如:http,fs、express,request…… 這部分在原始碼的lib目錄下可以看到;

2)Node bingdings: nodejs程式的main函式入口,還有提供給lib模組的C++類介面,這一層是javascript與底層C/C++溝通的橋樑,由C++編寫,這部分在原始碼的src目錄下可以看到;

3)最底層,支援Nodejs執行的關鍵: V8 引擎:用來解析、執行javascript程式碼的執行環境。 libuv: 提供最底層的IO操作介面,包括檔案非同步IO的執行緒池管理和網路的IO操作,是整個非同步IO實現的核心! 這部分由C/C++編寫,在原始碼的deps目錄下可以看到。

小結:我們其實對 Node.js的單執行緒一直有個很深的誤會。事實上,這裡的“單執行緒”指的是我們(開發者)編寫的程式碼只能執行在一個執行緒當中(習慣稱之為主執行緒),Node.js並沒有給 Javascript 執行時建立新執行緒的能力,所以稱為單執行緒,也就是所謂的主執行緒。 其實,Nodejs中許多非同步方法在具體的實現時(NodeJs底層封裝了Libuv,它提供了執行緒池、事件池、非同步I/O等模組功能,其完成了非同步方法的具體實現),內部均採用了多執行緒機制。

二、非同步IO操作呼叫流程

這裡寫圖片描述

這裡,主執行緒就是nodejs所謂的單執行緒,也就是使用者javascript程式碼執行的執行緒

IO執行緒是由Libuv(Linux下由libeio具體實現;window下則由IOCP具體實現)管理的執行緒池控制的,本質上是多執行緒。即採用了執行緒池與阻塞IO模擬了非同步IO。

以檔案操作為例子,回撥函式是何時被載入執行的呢?也就是非同步IO操作內部是如何實現的?

新建一個檔案yzx_file.js ,內容如下:

var fs = require('fs');
var path = require('path');

fs.readFile(__dirname + '/test01.txt'
, {flag: 'r+', encoding: 'utf8'}, function (err, data) { console.log(data); //列印test01.txt文字內容 });

這裡寫圖片描述

整個檔案操作的呼叫過程如下:

1)首先,使用者寫的javascript呼叫Node的核心模組fs.js ;

2)接下來,Node的核心模組呼叫C++內建模組node_file.cc ;

3)最後,根據不同平臺(Linux或者window),內建模組通過libuv進行系統呼叫

然後,接下來你可能會產生疑問:那回調函式何時被執行呢?

三、Nodejs執行流程

當你執行上面的例子,如 node yzx_file.js,剖析內部的具體流程。

這裡寫圖片描述

1)node啟動,進入main函式;

2)初始化核心資料結構 default_loop_struct;這個資料結構是事件迴圈的核心,當node執行到“載入js檔案”時,如果使用者的javascript程式碼中具有非同步IO操作時,如讀寫檔案。這時候,javascript程式碼呼叫–>lib模組–>C++模組–>libuv介面–>最終系統底層的API—>系統返回一個檔案描述符fd 和javascript程式碼傳進來的回撥函式callback,然後封裝成一個IO觀察者(一個uv__io_s型別的物件),儲存到default_loop_struct。

(檔案描述符的理解: 對於每個程式系統都有一張單獨的表。精確地講,系統為每個執行的程序維護一張單獨的檔案描述符表。當程序開啟一個檔案時,系統把一個指向此檔案內部資料結構的指標寫入檔案描述符表,並把該表的索引值返回給呼叫者 。應用程式只需記住這個描述符,並在以後操作該檔案時使用它。作業系統把該描述符作為索引訪問程序描述符表,通過指標找到儲存該檔案所有的資訊的資料結構。)

(觀察者的理解:在每個Tick(在程式啟動時,Node便會建立一個類似於while(true)的迴圈,沒執行一次迴圈體的過程我們稱為Tick)的過程中,為了判斷是否有事件需要處理,所以引入了觀察者的概念,每個事件迴圈中有一個或多個觀察者,判斷是否有事件要處理的過程就是向這些觀察者詢問是否有要處理的事件。在node中,事件主要來源於網路請求,檔案IO等,這些事件對應的觀察者有檔案I/O觀察者、網路I/O觀察者等。事件輪詢是一個典型的生產者、消費者模型,非同步I/O、網路請求等則是事件的生產者,源源不斷為node提供不同型別的事件,這些事件被傳遞到對應的觀察者那裡,事件迴圈則從觀察者那裡取出事件並處理。)

3)載入使用者javascript檔案,呼叫V8引擎介面,解析並執行javascript程式碼; 如果有非同步IO,則通過一系列呼叫系統底層API,若是網路IO,如http.get() 或者 app.listen() ;則把系統呼叫後返回的結果(檔案描述符fd)和事件繫結的回撥函式callback,一起封裝成一個IO觀察者,儲存到default_loop_struct;如果是檔案IO,例如在uv_fs_open()的呼叫過程中,我們建立了一個FSReqWrap請求物件。從JavaScript層傳入的引數和當前方法都被封裝在這個請求物件中,其中我們最為關心的回撥函式則被設定在這個物件的oncomplete_sym屬性上:req_wrap->object_->Set(oncomplete_sym, callback);物件包裝完畢後,在Windows下,則呼叫QueueUserWorkItem()方法將這個FSReqWrap物件推入執行緒池中等待執行,該方法的程式碼如下所示QueueUserWorkItem(&uv_fs_thread_proc, req, WT_EXECUTEDEFAULT);QueueUserWorkItem()方法接收3個引數:第一個引數是將要執行的方法的引用,這裡引用的是uv_fs_thread_proc,這個引數是uv_fs_thread_proc執行時所需要的引數;第三個引數是執行的標誌。當執行緒池中有可用執行緒時,我們會呼叫uv_fs_thread_proc()方法。uv_fs_thread_proc()方法會根據傳入引數的型別呼叫相應的底層函式。以uv_fs_open()為例,實際上呼叫的是fs__open()方法。

至此,JavaScript呼叫立即返回,由JavaScript層面發起的非同步呼叫的第一階段就此結束。JavaScript執行緒可以繼續執行當前任務的後續操作。當前的I/O操作線上程池中等待執行,不管它是否會阻塞I/O,都不會影響到JavaScript執行緒的後續執行,如此就達到到了非同步的目的。

4)進入事件迴圈,即呼叫libuv的事件迴圈入口函式uv_run();當處理完 js程式碼,如果有io操作,那麼這時default_loop_struct是儲存著對應的io觀察者的。處理完js程式碼,main函式繼續往下呼叫libuv的事件迴圈入口uv_run(),node程序進入事件迴圈:

uv_run()的while迴圈做的就是一件事,判斷default_loop_struct是否有存活的io觀察者。
    a. 如果沒有io觀察者,那麼uv_run()退出,node程序退出。
    b. 而如果有io觀察者,那麼uv_run()進入epoll_wait(),執行緒掛起等待,監聽對應的io觀察者是否有資料到來。有資料到來呼叫io觀察者裡儲存著的callback(js程式碼),沒有資料到來時一直在epoll_wait()進行等待。

這裡寫圖片描述

這裡寫圖片描述

5)這裡要強調的是:只有使用者的js程式碼全部執行完後,nodejs才呼叫libuv的事件迴圈入口函式uv_run(),即回撥函式才有可能被執行。所以,如果主執行緒的js程式碼呼叫了阻塞方法,那麼整個事件輪詢就會被阻塞,事件佇列中的事件便得不到及時處理。 為了驗證這個事實:我做了一個實驗如下:

新建 index.js檔案,內容如下:(同時在根目錄下新建一個test01.tet檔案,內容為“我是test01!”)

var fs = require('fs');
var path = require('path');

fs.readFile(__dirname + '/test01.txt', {flag: 'r+', encoding: 'utf8'}, function (err, data) {

    console.log(data); //列印test01.txt文字內  
});

//自己寫的一個延遲函式
function sleep(milliSeconds){
    var StartTime =new Date().getTime();
    while (new Date().getTime() <StartTime+milliSeconds);
}

sleep(5000);  //延遲5s

程式很簡單,即在主執行緒中,呼叫了一個阻塞函式,延時5s;執行程式,你會發現,
5s以後,非同步檔案操作的回撥函式才會被觸發執行。這也說明了,如果真正想做到非同步IO操作,主執行緒應該儘量避免大量的耗時計算或呼叫阻塞函式

總結:事件迴圈、觀察者、請求物件、IO執行緒池這四者共同構成了Node非同步IO操作的基本要素。