Node - 非同步IO和事件迴圈

菜的黑人牙膏發表於2019-05-04

前言

學習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, 如讀取檔案等採用的是執行緒池的方式來實現, 可以看到, Node通過另外一個執行緒來進行Io操作, 完成後再通知主執行緒:

Node - 非同步IO和事件迴圈

而在window下, 則是利用IOCP介面來完成, IOCP從使用者的角度來說確實是完美的非同步呼叫方式, 而實際也是利用核心中的執行緒池, 其與nix系統的不同在於後者的執行緒池是使用者層提供的執行緒池。

Node中的網路Io

在進入主題之前, 我們先了解下Linux的Io模式, 這裡推薦大家看這篇文章, 大致總結如下:

阻塞 I/O(blocking IO)

Node - 非同步IO和事件迴圈

所以,blocking IO的特點就是在IO執行的兩個階段都被block了。

非阻塞 I/O(nonblocking IO)

Node - 非同步IO和事件迴圈

當使用者程式發出read操作時,如果kernel中的資料還沒有準備好,那麼它並不會block使用者程式,而是立刻返回一個error。從使用者程式角度講 ,它發起一個read操作後,並不需要等待,而是馬上就得到了一個結果。使用者程式判斷結果是一個error時,它就知道資料還沒有準備好,於是它可以再次傳送read操作。一旦kernel中的資料準備好了,並且又再次收到了使用者程式的system call,那麼它馬上就將資料拷貝到了使用者記憶體,然後返回。

I/O 多路複用( IO multiplexing)

Node - 非同步IO和事件迴圈

所以,I/O 多路複用的特點是通過一種機制一個程式能同時等待多個檔案描述符,而這些檔案描述符(套接字描述符)其中的任意一個進入讀就緒狀態,select()函式就可以返回。

非同步 I/O(asynchronous IO)

Node - 非同步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進行輪詢的示意圖。

Node - 非同步IO和事件迴圈

select
select。它是在read的基礎上改進的一種方案,通過對檔案描述符上的事件狀態進行判斷。圖2是通過select進行輪詢的示意圖。select輪詢具有一個較弱的限制,那就是由於它採用一個1024長度的陣列來儲存狀態,也就是說它最多可以同時檢查1024個檔案描述符。

Node - 非同步IO和事件迴圈

poll
poll。poll比select有所改進,採用連結串列的方式避免陣列長度的限制,其次它可以避免不必要的檢查。但是檔案描述符較多的時候,它的效能是十分低下的。

Node - 非同步IO和事件迴圈

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

Node - 非同步IO和事件迴圈

除此之外, 另外的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中的事件迴圈有六個階段:

Node - 非同步IO和事件迴圈

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

  • 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++核心模組進行下層的操作。

Node - 非同步IO和事件迴圈

從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的流程:

Node - 非同步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

相關文章