前言
學習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
, 其具有兩個步驟
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還具有以下的缺點(引用自 文章 ):
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中的事件循環有六個階段:
其中的每個階段都會處理相關的事件:
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的語法更自然更貼近, 也就更適合。
以上就是本文的全部內容,希望對大家的學習有所幫助,也希望大家多多支持VeVb武林網。
新聞熱點
疑難解答