為什么要異步I/O?
- 從用戶體驗角度講,異步IO可以消除UI阻塞,快速響應資源
- JavaScript是單線程的,它與UI渲染共用一個線程。所以在JavaScript執行的時候,UI渲染將處於停頓的狀態,用戶體驗較差。而異步請求可以在下載資源的時候,JavaScript和UI渲染都同時執行,消除UI阻塞,降低響應資源需要的時間開銷。
- 假如一個資源來自兩個不同位置的數據的返回,第一個資源需要M毫秒的耗時,第二個資源需要N毫秒的耗時。當采用同步的方式,總耗時為(M+N)毫秒,代碼大致如下:
//耗時為M毫秒 getData('from_db'); //耗時為N毫秒 getData('from_remote_api');
當采用異步的方式,總耗時為max(M,N),代碼大致如下:
getData('from_db',function(result){ //消費時間為M }); getData('from_remote_api',function(result){ //消費時間為N });
隨着應用的復雜性,情景會變成M+N+...和max(M,N,...),此時同步和異步的優劣就會更加凸顯。另一方面,隨着網站和應用的擴展,數據往往會分布到多台服務器上,而分布意味着M和N的值會線性增長,這也會放大異步和同步在性能上的差異。總之,IO是昂貴的,分布式IO是更昂貴的!
- 從資源分配角度講,異步IO可以讓單線程遠離阻塞,以更好地利用CPU
- 假設業務線上有一組互不相關的任務需要完成,現行的主流方法有以下兩種:
- 單線程同步執行:會阻塞IO導致硬件資源和CPU得不到更優的使用
- 多線程並發執行:會出現死鎖、狀態同步等問題
- Node的解決方案
- 利用單線程,遠離多線程的死鎖、狀態同步等問題;
- 利用異步I/O,讓單線程遠離阻塞,更好的利CPU
- 假設業務線上有一組互不相關的任務需要完成,現行的主流方法有以下兩種:

異步IO實現現狀?
- I/O的阻塞與非阻塞:IO對於操作系統內核而言,只有阻塞與非阻塞兩種方式。阻塞模式的I/O會造成應用程序等待,直到I/O完成,會造成CPU等待IO,浪費等待時間,CPU的處理能力不能充分利用。同時操作系統也支持將I/O操作設置為非阻塞模式,這時應用程序的調用將可能在沒有拿到真正數據時就立即返回了,為此應用程序需要多次調用才能確認I/O操作完全完成,但由於IO並沒有完成,立即返回的並不是業務層期望的數據,僅僅是當前調用的狀態。
- I/O的同步與異步:I/O的同步與異步出現在應用程序中。如果做阻塞I/O調用,應用程序等待調用的完成的過程就是一種同步狀況。相反,I/O為非阻塞模式時,應用程序則是異步的。
圖2-阻塞IO調用示意圖 圖3-非阻塞IO調用示意圖
為了獲取完整的數據,應用程序需要重復調用IO操作來確認是否完成,叫做輪詢。輪詢技術主要有以下這些:
- read:它是最原始,性能最低的一種。
- select:在read基礎上的改進方案,通過對文件描述符上的事件狀態進行判斷。
- poll:較select有所改進,采用鏈表的方式避免數據長度的限制,其次它能避免不必要的檢查。但在文件描述符過多時,性能十分低下。
- epoll:該方案是Linux下效率最高的IO事件通知機制,具體方法見圖。
圖4-read方式 圖5-select方式
圖6-poll方式 圖7-epoll方式
盡管epoll已經利用事件來降低了CPU的耗用,但是休眠期間CPU是閑置的,對於當前線程而言利用率不夠。那么,是否有一種理想的異步I/O呢?
答案是當然有!理想的異步I/O實現如下圖所示:
圖8-理想中的異步IO方式示意圖
天真的程序員們曾經幻想:我們可以通過信號或回調將數據傳遞給應用程序啊!
的確,Linux下提供了一種異步IO的方式(AIO),它就是通過信號或回調傳遞數據的。
但不幸的是,只有Linux下有,而且還存在一些如系統緩存無法利用的缺陷。
后來,經過反復的思考與實踐,現實中的異步IO是這樣實現的:
圖9-現實中的異步IO示意圖
其核心思想是:利用多線程,主線程負責計算,IO線程負責IO操作,線程間通過信號進行通信。
聰明的你也許會問:Node不是單線程嗎,如何實現多線程呢?
其實,Node底層是可以實現多線程的,只是上層提供給用戶的JavaScript是單線程的。而這里,我們探討的正是Node底層是如何實現異步IO的?
如果再細一點,其實Linux是利用如上的多線程創建線程池實現的,而Windows則是利用IOCP創建的。
圖10-Node異步IO底層實現框架圖
Node異步IO的實現?
在清楚了Node底層對異步IO的實現原理后,我們就可以進一步理解一個Node進程是如何實現完整的異步IO的了!
- Node的異步I/O模型:事件循環、觀察者、請求對象、執行回調是四個核心概念。
- 事件循環:進程啟動時,Node會創建一個類似while(true)的循環,判斷是否有事件需要處理,若有,取出事件並執行回調函數。

-
- 觀察者:觀察者是用來判斷是否有事件需要處理。事件循環中有一到多個觀察者,判斷過程會向觀察者詢問是否有需要處理的事件。這個過程類似於飯店的廚師與前台服務員的關系。廚師每做完一輪菜,就會向前台服務員詢問是否有要做的菜,如果有就繼續做,沒有的話就下班了。這一過程中,前台服務員就相當於觀察者,她收到的顧客點單就是回調函數。
注:事件循環是一個典型的生產者/消費者模型。異步I/O、網絡請求是生產者,而事件循環則從觀察者那里取出事件並處理。
-
- 請求對象:實際上,從JavaScript發起調用到內核執行完I/O操作的過渡過程中,存在一種中間產物,叫做請求對象。以fs.open( )方法為例,
fs.open = function(path, flags, mode, callback) { //... binding.open(pathModule._makeLong(path), stringToFlags(flags), mode, callback); };
- 請求對象:實際上,從JavaScript發起調用到內核執行完I/O操作的過渡過程中,存在一種中間產物,叫做請求對象。以fs.open( )方法為例,

整個調用過程:JavaScript -> Node核心模塊 -> C++內建模塊 -> libuv系統調用
在uv_fs_open的調用過程中,Node.js創建了一個FSReqWrap請求對象。從JavaScript傳入的參數和當前方法都被封裝在這個請求對象中,其中回調函數則被設置在這個對象的oncomplete_sym屬性上。
req_wrap->object_->Set(oncomplete_sym, callback);
對象包裝完畢后,調用QueueUserWorkItem方法將這個FSReqWrap對象推入線程池中等待執行。
QueueUserWorkItem(&uv_fs_thread_proc, req, WT_EXECUTELONGFUNCTION)
QueueUserWorkItem接受三個參數,第一個是要執行的方法,第二個是方法的上下文,第三個是執行的標志。
至此,由JavaScript層面發起的異步調用第一階段就此結束。
-
- 執行回調:組裝好請求對象,送入I/O線程池等待執行,實際上完成了異步I/O的第一部分,回調通知是第二部分。當線程池中有可用線程的時候調用uv_fs_thread_proc方法執行。該方法會根據傳入的類型調用相應的底層函數,以uv_fs_open為例,實際會調用到fs__open方法。調用完畢之后,會將獲取的結果設置在req->result上。然后調用PostQueuedCompletionStatus通知我們的IOCP*對象操作已經完成,並將線程歸還給線程池。
PostQueuedCompletionStatus((loop)->iocp, 0, 0, &((req)->overlapped)
PostQueuedCompletionStatus方法的作用是向創建的IOCP上相關的線程通信,線程根據執行狀況和傳入的參數判定退出。
在這一過程中,每次事件循環會調用GetQueuedCompletionStatus()方法檢查線程池中是否有執行完的請求,若有,會將請求對象加入到I/O觀察者的隊列中,將其作為事件處理。
I/O觀察者回調函數的行為就是取出請求對象的result屬性作為參數,取出oncomplete_sym屬性作為方法,然后調用執行,以此達到執行回調函數的目的。

圖13-Node整個異步IO流程示意圖
總結
- JavaScript是單線程的,但Node本身其實是多線程的,除了用戶代碼無法並行執行外,所有的I/O請求是可以並行執行的。
- 事件循環是Node異步I/O實現的核心,Node通過事件驅動的方式處理請求,使得其無須為每個請求創建額外的線程,省掉了創建和銷毀線程的開銷。同時也因為線程數較少,不受線程上下文切換的影響,維持了Node的高性能。
- Node異步IO、非阻塞的特性,使它非常適用於IO密集、高並發的應用場景。