Node.js異步IO原理剖析


為什么要異步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
                   
                             圖1-異步I/O調用示意圖

異步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)的循環,判斷是否有事件需要處理,若有,取出事件並執行回調函數。
             
                      圖11-Node事件循環示意圖
    • 觀察者:觀察者是用來判斷是否有事件需要處理。事件循環中有一到多個觀察者,判斷過程會向觀察者詢問是否有需要處理的事件。這個過程類似於飯店的廚師與前台服務員的關系。廚師每做完一輪菜,就會向前台服務員詢問是否有要做的菜,如果有就繼續做,沒有的話就下班了。這一過程中,前台服務員就相當於觀察者,她收到的顧客點單就是回調函數。

           注:事件循環是一個典型的生產者/消費者模型。異步I/O、網絡請求是生產者,而事件循環則從觀察者那里取出事件並處理。

    • 請求對象:實際上,從JavaScript發起調用到內核執行完I/O操作的過渡過程中,存在一種中間產物,叫做請求對象。以fs.open( )方法為例,
      fs.open = function(path, flags, mode, callback) {
      
      //...
      
      binding.open(pathModule._makeLong(path),
      
      stringToFlags(flags),
      
      mode,
      
      callback);
      
      };
      

        

       這個函數的作用是根據指定的路徑和參數去打開一個文件,從而得到一個文件描述符,是后續所有I/O操作的初始操作。

 

              
                                            圖12-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密集、高並發的應用場景。

 

 


免責聲明!

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



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