Node - 異步IO和事件循環

前言

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

一些概念

同步異步 & 阻塞非阻塞

查閱資料的時候, 發現很多人都對異步和非阻塞的概念有點混淆, 其實兩者是完全不同的, 同步異步指的是行爲即兩者之間的關係, 而阻塞非阻塞指的是狀態即某一方

以前端請求爲一個例子,下面的代碼很多人都應該寫過

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

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

如果是異步的話, 那麼應該是client發起請求後, 立即返回, 而請求可能還沒有到達server端或者請求正在處理, 當然在異步情況下, client端通常會註冊事件來處理請求完成後的情況, 如上面的succeed函數。

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

如果是阻塞的話, 那麼該線程就會一直等到這個請求完成之後才能被釋放用於其他請求

如果是非阻塞的話, 那麼該線程就可以發起請求後而不用等請求完成繼續做其他事情

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

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章