深入剖析Nodejs的異步IO


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


一、首先,看看Nodejs的架構

這里寫圖片描述

http://nodejs.cn/download/ 你可以到Nodejs中文網下載Node源碼。

這里寫圖片描述

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
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

這里寫圖片描述

整個文件操作的調用過程如下:

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操作的基本要素。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM