Node - 異步IO和事件循環

前言

學習Node就繞不開異步IO, 異步IO又與事件循環息息相關, 而關於這一塊一直沒有仔細去了解整理過, 恰好最近在作項目的時候, 有了一些思考就記錄了下來, 但願能儘可能將這一塊的知識整理清楚, 若有錯誤, 請指點輕噴~~html

一些概念

同步異步 & 阻塞非阻塞

查閱資料的時候, 發現不少人都對異步和非阻塞的概念有點混淆, 其實二者是徹底不一樣的, 同步異步指的是行爲即二者之間的關係, 而阻塞非阻塞指的是狀態即某一方前端

之前端請求爲一個例子,下面的代碼不少人都應該寫過node

$.ajax(url).succedd(() => {
    ......
    // to do something
})

同步異步
若是是同步的話, 那麼應該是client發起請求後, 一直等到serve處理請求完成後才返回繼續執行後續的邏輯, 這樣client和serve之間就保持了同步的狀態nginx

若是是異步的話, 那麼應該是client發起請求後, 當即返回, 而請求可能尚未到達server端或者請求正在處理, 固然在異步狀況下, client端一般會註冊事件來處理請求完成後的狀況, 如上面的succeed函數。ajax

阻塞非阻塞
首先須要明白一個概念, Js是單線程, 可是瀏覽器並非, 事實上你的請求是瀏覽器的另外一個線程在跑。 segmentfault

若是是阻塞的話, 那麼該線程就會一直等到這個請求完成以後才能被釋放用於其餘請求數組

若是是非阻塞的話, 那麼該線程就能夠發起請求後而不用等請求完成繼續作其餘事情瀏覽器

總結
之因此常常會混亂是由於沒有說清楚討論的是哪一部分(下面會提到), 因此同步異步討論的對象是雙方, 而阻塞非阻塞討論的對象是自身服務器

IO和CPU

Io和Cpu是能夠同時進行工做的網絡

IO:

I/O(英語:Input/Output),即輸入/輸出,一般指數據在內部存儲器和外部存儲器或其餘周邊設備之間的輸入和輸出。

cpu

解釋計算機指令以及處理計算機軟件中的數據。

Node中的異步IO模型

IO分爲磁盤IO和網絡IO, 其具備兩個步驟

  1. 等待數據準備 (Waiting for the data to be ready)
  2. 將數據從內核拷貝到進程中 (Copying the data from the kernel to the process)

Node中的磁盤Io

如下的討論基於*nix系統。
理想的異步Io應該像上面討論的同樣, 如圖:

而實際上, 咱們的系統並不能完美的實現這樣的一種調用方式, Node的異步IO, 如讀取文件等採用的是線程池的方式來實現, 能夠看到, Node經過另一個線程來進行Io操做, 完成後再通知主線程:

而在window下, 則是利用IOCP接口來完成, IOCP從用戶的角度來講確實是完美的異步調用方式, 而實際也是利用內核中的線程池, 其與nix系統的不一樣在於後者的線程池是用戶層提供的線程池。

Node中的網絡Io

在進入主題以前, 咱們先了解下Linux的Io模式, 這裏推薦你們看這篇文章, 大體總結以下:

阻塞 I/O(blocking IO)

因此,blocking IO的特色就是在IO執行的兩個階段都被block了。

非阻塞 I/O(nonblocking IO)

當用戶進程發出read操做時,若是kernel中的數據尚未準備好,那麼它並不會block用戶進程,而是馬上返回一個error。從用戶進程角度講 ,它發起一個read操做後,並不須要等待,而是立刻就獲得了一個結果。用戶進程判斷結果是一個error時,它就知道數據尚未準備好,因而它能夠再次發送read操做。一旦kernel中的數據準備好了,而且又再次收到了用戶進程的system call,那麼它立刻就將數據拷貝到了用戶內存,而後返回。

I/O 多路複用( IO multiplexing)

因此,I/O 多路複用的特色是經過一種機制一個進程能同時等待多個文件描述符,而這些文件描述符(套接字描述符)其中的任意一個進入讀就緒狀態,select()函數就能夠返回。

異步 I/O(asynchronous IO)

用戶進程發起read操做以後,馬上就能夠開始去作其它的事。而另外一方面,從kernel的角度,當它受到一個asynchronous read以後,首先它會馬上返回,因此不會對用戶進程產生任何block。而後,kernel會等待數據準備完成,而後將數據拷貝到用戶內存,當這一切都完成以後,kernel會給用戶進程發送一個signal,告訴它read操做完成了。

而在Node中, 採用的是I/O 多路複用的模式, 而在I/O多路複用的模式中, 又具備read, select, poll, epoll等幾個子模式, Node採用的是最優的epoll模式, 這裏簡單說下其中的區別, 而且解釋下爲何epoll是最優的。

read
read。它是一種最原始、性能最低的一種,它會重複檢查I/O的狀態來完成數據的完整讀取。在獲得最終數據前,CPU一直耗用在I/O狀態的重複檢查上。圖1是經過read進行輪詢的示意圖。

select
select。它是在read的基礎上改進的一種方案,經過對文件描述符上的事件狀態進行判斷。圖2是經過select進行輪詢的示意圖。select輪詢具備一個較弱的限制,那就是因爲它採用一個1024長度的數組來存儲狀態,也就是說它最多能夠同時檢查1024個文件描述符。

poll
poll。poll比select有所改進,採用鏈表的方式避免數組長度的限制,其次它能夠避免沒必要要的檢查。可是文件描述符較多的時候,它的性能是十分低下的。

epoll
該方案是Linux下效率最高的I/O事件通知機制,在進入輪詢的時候若是沒有檢查到I/O事件,將會進行休眠,直到事件發生將它喚醒。它是真實利用了事件通知,執行回調的方式,而不是遍歷查詢,因此不會浪費CPU,執行效率較高。

除此以外, 另外的poll和select還具備如下的缺點(引用自文章):

  1. 每次調用select,都須要把fd集合從用戶態拷貝到內核態,這個開銷在fd不少時會很大
  2. 同時每次調用select都須要在內核遍歷傳遞進來的全部fd,這個開銷在fd不少時也很大
  3. select支持的文件描述符數量過小了,默認是1024

epoll對於上述的改進

epoll既然是對select和poll的改進,就應該能避免上述的三個缺點。那epoll都是怎麼解決的呢?在此以前,咱們先看一下epoll和select和poll的調用接口上的不一樣,select和poll都只提供了一個函數——select或者poll函數。而epoll提供了三個函數,epoll_create,epoll_ctl和epoll_wait,epoll_create是建立一個epoll句柄;epoll_ctl是註冊要監聽的事件類型;epoll_wait則是等待事件的產生。
  對於第一個缺點,epoll的解決方案在epoll_ctl函數中。每次註冊新的事件到epoll句柄中時(在epoll_ctl中指定EPOLL_CTL_ADD),會把全部的fd拷貝進內核,而不是在epoll_wait的時候重複拷貝。epoll保證了每一個fd在整個過程當中只會拷貝一次。
  對於第二個缺點,epoll的解決方案不像select或poll同樣每次都把current輪流加入fd對應的設備等待隊列中,而只在epoll_ctl時把current掛一遍(這一遍必不可少)併爲每一個fd指定一個回調函數,當設備就緒,喚醒等待隊列上的等待者時,就會調用這個回調函數,而這個回調函數會把就緒的fd加入一個就緒鏈表)。epoll_wait的工做實際上就是在這個就緒鏈表中查看有沒有就緒的fd(利用schedule_timeout()實現睡一會,判斷一會的效果,和select實現中的第7步是相似的)。
  對於第三個缺點,epoll沒有這個限制,它所支持的FD上限是最大能夠打開文件的數目,這個數字通常遠大於2048,舉個例子,在1GB內存的機器上大約是10萬左右,通常來講這個數目和系統內存關係很大。

Node中的異步網絡Io就是利用了epoll來實現, 簡單來講, 就是利用一個線程來管理衆多的IO請求, 經過事件機制實現消息通信。

事件循環

理解了Node中磁盤IO和網絡IO的底層實現後, 基於上面的代碼, 能夠看出Node是基於事件註冊的方式在完成Io後進行一系列的處理, 其內部是利用了事件循環的機制。

關於事件循環, 是指JS在每次執行完同步任務後會檢查執行棧是否爲空, 是的話就會去執行註冊的事件列表, 不斷的循環該過程。Node中的事件循環有六個階段:

其中的每一個階段都會處理相關的事件:

  • timers: 執行setTimeout和setInterval中到期的callback。
  • pending callback: 執行延遲到下一個循環迭代的 I/O 回調。
  • idle, prepare:僅系統內部使用。
  • poll:檢索新的 I/O 事件;執行與 I/O 相關的回調(幾乎全部狀況下,除了關閉的回調函數,它們由計時器和 setImmediate() 排定的以外),其他狀況 node 將在此處阻塞。(即本文的內容相關))
  • check: setImmediate() 回調函數在這裏執行。
  • close callbacks: 執行close事件的callback,例如socket.on('close'[,fn])或者http.server.on('close, fn)。

ok, 這樣就解釋了Node是如何執行咱們註冊的事件, 那麼還缺乏一個環節, Node又是怎麼把事件和IO請求對應起來呢? 這裏涉及到了另一種中間產物請求對象。
以打開一個文件爲例子:

fs.open = function(path, flags, mode, callback){

//...

binding.open(pathModule._makeLong(path), stringToFlags(flags), mode, callback);

}

fs.open()的做用是根據指定路徑和參數去打開一個文件,從而獲得一個文件描述符,這是後續全部I/O操做的初始操做。從前面的代碼中能夠看到,JavaScript層面的代碼經過調用C++核心模塊進行下層的操做。

從JavaScript調用Node的核心模塊,核心模塊調用C++內建模塊,內建模塊經過libuv進行系統調用,這是Node裏經典的調用方式。這裏libuv做爲封裝層,有兩個平臺的實現,實質上是調用了uv_fs_open()方法。在uv_fs_open()的調用過程當中,咱們建立了一個FSReqWrap請求對象。從JavaScript層傳入的參數和當前方法都被封裝在這個請求對象中,其中咱們最爲關注的回調函數則被設置在這個對象的oncomplete_sym屬性上:
req_wrap->object_->Set(oncomplete_sym, callback);
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線程的後續執行,如此就達到了異步的目的。

請求對象是異步I/O過程當中的重要中間產物,全部的狀態都保存在這個對象中,包括送入線程池等待執行以及I/O操做完畢後的回調處理。
關於這一塊其實我的認爲不用過於細究, 大體上知道有這麼一個請求對象便可, 最後總結一下整個異步IO的流程:

圖引用自深刻淺出NodeJs

至此, Node的整個異步Io流程都已經清晰了, 它是依賴於IO線程池epoll、事件循環、請求對象共同構成的一個管理機制。

Node爲何更適合IO密集

Node爲人津津樂道的就是它更適合IO密集型的系統, 而且具備更好的性能, 關於這一點其實與它的異步IO息息相關。

對於一個request而言, 若是咱們依賴io的結果, 異步io和同步阻塞io(每線程/每請求)都是要等到io完成才能繼續執行. 而同步阻塞io, 一旦阻塞就不會在得到cpu時間片, 那麼爲何異步的性能更好呢?

其根本緣由在於同步阻塞Io須要爲每個請求建立一個線程, 在Io的時候, 線程被block, 雖然不消耗cpu, 可是其自己具備內存開銷, 當大併發的請求到來時, 內存很快被用光, 致使服務器緩慢, 在加上, 切換上下文代價也會消耗cpu資源。而Node的異步Io是經過事件機制來處理的, 它不須要爲每個請求建立一個線程, 這就是爲何Node的性能更高。

特別是在Web這種IO密集型的情形下更具優點, 除開Node以外, 其實還有另一種事件機制的服務器Ngnix, 若是明白了Node的機制對於Ngnix應該會很容易理解, 有興趣的話推薦看這篇文章

總結

在真正的學習Node異步IO以前, 常常看到一些關於Node適不適合做爲服務器端的開發語言的爭論, 固然也有不少片面的說法。
其實, 關於這個問題仍是取決於你的業務場景。

假設你的業務是cpu密集型的, 那你採用Node來開發, 確定是不適合的。 爲何不適合? 由於Node是單線程, 你被阻塞在計算的時候, 其餘的事件就作不了, 處理不了請求, 也處理不了回調。

那麼在IO密集型中, Node就比Java好嗎? 其實也不必定, 仍是要取決於你的業務。 若是你的業務是很是大的併發, 可是你的服務器資源又有限, 就比如如今有個入口, Node能夠一次進10我的, 而Java依次排隊進一我的, 若是是10我的同時進, 固然是Node更具備優點, 可是假設有100我的(如1w個異步請求之類)的話, 那麼Node就會由於它的異步機制致使應用被掛起,內存狂飆,IO堵塞,並且不可恢復,這個時候你只能重啓了。而Java卻能夠有序的處理, 雖然會慢一點。 而一臺服務器掛了形成的線上事故的損失更是不可衡量的。(固然, 若是服務器資源足夠的話, Node也能處理)。

最後, 事實上Java也是具備異步IO的庫, 只是相對來講, Node的語法更天然更貼近, 也就更適合。

參考&引用

怎樣理解阻塞非阻塞與同步異步的區別?
Linux epoll & Node.js Event Loop & I / O複用
node.js應用高併發高性能的核心關鍵本質是什麼?
Linux IO模式及 select、poll、epoll詳解
異步IO比同步阻塞IO性能更好嗎?爲何?
深刻淺出Nodejs

相關文章
相關標籤/搜索