淺談Node的異步I/O

本文章是通過閱讀深入淺出Node.js這本書寫的總結,有什麼問題,歡迎大家指出。

爲什麼要異步 I/O

異步I/O爲何在Node裏這麼重要,這和Node面向網絡設計有關。現如今Web應用已不再是單臺服務器就能勝任的時代了,在跨網絡的結構下,併發已經是現代編程中的標準配備了。具體到實處,可從用戶體驗和資源分配這兩個方面說起。

用戶體驗
  1. 在現如今跨網絡的結構下,併發已經是現代編程中的標配了;
  2. 在瀏覽器中JavaScript在單線程上執行,而且它還與UI渲染共用一個線程。這意味着JavaScript在執行的時候UI渲染和響應是處於停滯狀態的;
  3. 前端通過異步可以消除掉UI阻塞的現象;
  4. 採用異步請求,可以併發的下載資源。在下載資源期間,JavaScript和UI的執行都不會處於等待狀態,可以繼續響應用戶的交互行爲,給用戶一個鮮活的頁面;
資源分配

假設業務場景中有一組互不相關的任務要完成,現行的主流方法有以下兩種。

  • 單線程串行依次執行;
  • 多線程並行完成;
  1. 多線程的代價在於創建線程和執行期線程上下文切換的開銷較大;另外,在複雜的業務中,多線程編程經常面臨鎖、狀態同步等問題,這是多線程被詬病的主要原因。但是多線程在多核CPU上能夠有效提升CPU的利用率,這個優勢是毋庸置疑的。

  2. 單線程順序執行任務的方式比較符合編程人員按順序思考的思維方式。它依然是最主流的編程方式,因爲它易於表達。但是串行執行的缺點在於性能,任意一個比較慢的任務都會導致後續執行代碼被阻塞。

從上面我們知道單線程同步編程模型會因阻塞I/O導致硬件資源得不到更好的使用。多線程編程模型也因爲編程中的死鎖、狀態同步等問題讓人頭疼。

Node面對上面的問題給出了它的解決方案:利用單線程,遠離多線程死鎖、狀態同步等問題;利用異步I/O,讓單線程遠離阻塞,以更好地使用CPU。

異步I/O是Node的一大特色,它是首個大規模將異步I/O應用在應用層上的平臺,它在單線程上將資源分配得更高效。爲了解決單線程無法利用多核CPU的缺點,Node提供了子進程,該子進程可以通過工作進程高效地利用CPU和I/O。

我們通過一張經典圖看下異步I/O的調用示意圖:

在這裏插入圖片描述
這就是爲什麼異步I/O在Node中如此盛行,甚至將其作爲主要理念進行設計的原因。

異步 I/O 實現現狀

操作系統內核對於I/O只有兩種方式阻塞與非阻塞

  1. 非阻塞I/O跟阻塞I/O的差別爲調用之後會立即返回
  2. 阻塞I/O造成CPU等待浪費
  3. 非阻塞帶來的問題是需要通過輪詢去確認是否完全完成進行最終數據獲取。(read、select、poll、epoll、kqueue)
阻塞I/O

在調用阻塞I/O時,應用程序需要等待I/O完成才返回結果,阻塞I/O的一個特點是調用之後一定要等到系統內核層面完成所有操作後,調用才結束。比如:我們在讀取磁盤上的一段文件時,系統內核在完成磁盤尋道、讀取數據、複製數據到內存中之後,這個調用才結束。

阻塞I/O造成CPU等待I/O,浪費等待時間,CPU的處理能力不能得到充分利用。我們可以通過下面這張經典圖看下:
在這裏插入圖片描述

非阻塞I/O

爲了提高性能,內核提供了非阻塞I/O,非阻塞I/O返回之後,CPU的時間片可以用來處理其他事務,此時的性能提升是明顯的。我們可以通過下面這張圖看下:

在這裏插入圖片描述
但非阻塞I/O也存在一些問題。因爲完整的I/O並沒有完成,立即返回的並不是業務層期望的數據,而僅僅是當前調用的狀態。爲了獲取完整的數據,應用程序需要重複調用I/O操作來確認是否完成。這種重複調用判斷操作是否完成的技術叫做輪詢

從上面我們可以看出任意技術都不是完美的。阻塞I/O造成CPU等待浪費,非阻塞帶來的問題卻是需要輪詢去確認是否完全完成,並盡心數據獲取,它會讓CPU處理狀態判斷,是對CPU資源的浪費。

現存的輪詢技術主要有以下這些:
  • read: 它是最原始、性能最低的一種,通過重複調用來檢查I/O的狀態來完成完整數據的讀取。在得到最終數據前,CPU一直耗用在等待上。如下圖所示:
    在這裏插入圖片描述
  • select:它是在read的基礎上改進的一種方案,通過對文件描述符上的事件狀態來進行判斷。select輪詢有一個較弱的限制,由於它採用一個1024長度的數組來存儲狀態,所以它最多可以同時檢查1024個文件描述符。如下圖所示:
    在這裏插入圖片描述
  • poll:該方案比select有所改進,採用鏈表的方式避免數組長度的限制,其次它能避免不需要的檢查。但是當文件描述符較多的時候,它的性能還是十分低下的。與select相似,但性能限制有所改善。如下圖所示:
    在這裏插入圖片描述
  • epoll:該方案是Linux下效率最高的I/O事件通知機制,在進入輪詢的時候如果沒有檢查到I/O事件,將會進行休眠,直到事件發生將它喚醒。它真實利用了事件通知、執行回調的方式,而不是遍歷查詢,所以不會浪費CPU,執行效率較高。如下圖所示:
    在這裏插入圖片描述
  • kqueue:該方案的實現方式與epoll類似,但是它僅在FreeBSD系統下存在。
理想的非阻塞異步I/O

上面的epoll已經利用了事件來降低CPU的耗用,但是休眠期間CPU幾乎是閒置的,對於當前線程而言利用率不夠。是否有一種理想的異步I/O呢?我們期望的完美的異步I/O應該是應用程序發起非阻塞調用,無須通過遍歷或者事件喚醒等方式輪詢,可以直接處理下一個任務,只需在I/O完成後通過信號或回調將數據傳遞給應用程序即可。如下圖所示:
在這裏插入圖片描述
我們知道在Linux下存在這樣一種方式,它原生提供的一種異步I/O方式(AIO)就是通過信號或回調來傳遞數據的。但是,只有Linux下有,而且它還有缺陷——AIO僅支持內核I/O中的O_DIRECT方式讀取,導致無法利用系統緩存。

現實的異步I/O

現實是要達成異步I/O的目標,並不是很難。前面我們限定在了單線程的狀況下,那麼多線程的方式通過讓部分線程進行阻塞I/O或者非阻塞I/O加輪詢技術來完成數據獲取,讓一個線程進行計算處理,通過線程之間的通信將I/O得到的數據進行傳遞,這就輕鬆實現了異步I/O(它是模擬的),如下圖所示:
在這裏插入圖片描述

這相當於是java中常用到的線程池概念。

Node 的異步 I/O

  1. 事件循環:在進程啓動時,node便會創建事件循環,循環執行事件關聯的回調。
  2. 觀察者:每個事件循環中有一個或多個觀察者,觀察者決定是否要執行事件。
  3. 請求對象:從javascript發起調用到內核執行完I/O操作的過程中的中間對象。
  4. 執行回調: 組裝好請求對象、送入I/O線程池等待執行,實際上完成了異步I/O的第一部分,回調通知是第二部分。
事件循環

在進程啓動時,Node便會創建一個類似於while(true)的循環,每執行一次循環體的過程我們稱爲Tick。每個Tick的過程就是查看是否有事件待處理,如果有,就取出事件及其相關的回調函數。如果存在關聯的回調函數,就執行它們。然後進入下個循環,如果沒有事件處理,就退
出進程。流程圖如下所示:
在這裏插入圖片描述

觀察者

在每個Tick的過程中,如何判斷是否有事件需要處理呢?在這裏就必須要引入觀察者了。每個事件循環中有一個或者多個觀察者,而判斷是否有事件要處理的過程就是向這些觀察者詢問是否有要處理的事件。

我們通過一個比較形象的例子來形容一下:

這個過程就如同飯館的廚房,廚房一輪一輪地製作菜餚,但是要具體制作哪些菜餚取決於收銀臺收到的客人的下單。廚房每做完一輪菜餚,就去問收銀臺的小妹,接下來有沒有要做的菜,如果沒有的話,就下班打烊了。在這個過程中,收銀臺的小妹就是觀察者,她收到的客人點單就
是關聯的回調函數。當然,如果飯館經營有方,它可能有多個收銀員,就如同事件循環中有多個觀察者一樣。收到下單就是一個事件,一個觀察者裏可能有多個事件。

瀏覽器採用了類似的機制。事件可能來自用戶的點擊或者加載某些文件時產生,而這些產生的事件都有對應的觀察者。在Node中,事件主要來源於網絡請求、文件I/O等,這些事件對應的觀察者有文件I/O觀察者、網絡I/O觀察者等。觀察者將事件進行了分類。事件循環是一個典型的生產者/消費者模型。異步I/O、網絡請求等則是事件的生產者,源源不斷爲Node提供不同類型的事件,這些事件被傳遞到對應的觀察者那裏,事件循環則從觀察者那裏取出事件並處理。在Windows下,這個循環基於IOCP創建,而在*nix下則基於多線程創建。

請求對象

對Node中的異步I/O調用來說,回調函數不由開發者來調用。那從我們發出調用後到回調函數被執行,中間發生了什麼?事實上,JavaScript發起調用到內核執行完I/O操作的過渡過程中,存在一種中間產物,它叫做請求對象

請求對象是異步I/O過程中的重要中間產物,所有的狀態都保存在這個對象中,包括送入線程池等待執行以及I/O操作完畢後的回調處理。

我們通過一個例子fs.open來探索Node與底層之間是如何執行異步I/O調用以及回調函數究竟是如何被調用執行的:

// 異步地打開文件
fs.open(path[, flags[, mode]], callback)

fs.open()是根據指定路徑和參數去打開一個文件,從而得到一個文件描述符。這是後續所有I/O操作的初始操作。我們可以通過一張圖看下fs.open的底層執行過程:
在這裏插入圖片描述

從JavaScript調用Node的核心模塊,核心模塊調用C++內建模塊,內建模塊通過libuv進行系統調用,這是Node裏經典的調用方式。這裏libuv作爲封裝層,有兩個平臺的實現,實質上是調用了uv_fs_open()方法。在uv_fs_open()的調用過程中,我們創建了一個FSReqWrap請求對象。從JavaScript層傳入的參數和當前方法都被封裝在這個請求對象中,其中我們最爲關注的回調函數則被設置在這個對象的oncomplete_sym屬性上:

FSReqWrap->object_->Set(oncomplete_sym, callback);

FSReqWrap對象包裝完畢後,在Windows下,調用QueueUserWorkItem()方法將這個FSReqWrap對象推入線程池中等待執行,該方法的代碼如下所示:

QueueUserWorkItem(&uv_fs_thread_proc, req, WT_EXECUTEDEFAULT)

QueueUserWorkItem方法接受三個參數:

  • 第一個參數:是將要執行的方法的引用,這裏引用的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的第一部分,回調通知是第二部分。

線程池中的I/O操作調用完畢之後,將獲取的結果儲存在req->result屬性上,然後調用PostQueuedCompletionStatus()通知IOCP,告知當前對象操作已經完成:

PostQueuedCompletionStatus()方法的作用是向IOCP提交執行狀態,並將線程歸還線程池。通過PostQueuedCompletionStatus()方法提交的狀態,可通過GetQueuedCompletionStatus()提取。

在這個過程中,其實還用到了事件循環的I/O觀察者。在每次Tick的執行中,它會調用IOCP的GetQueuedCompletionStatus()方法檢查線程池中是否有執行完的請求,如果有,則將請求對象加入到I/O觀察者的隊列中,然後將其當做事件處理。

I/O觀察者回調函數的行爲就是取出請求對象的result屬性作爲參數,取出oncomplete_sym屬性作爲方法,然後調用執行,至此,整個異步I/O的流程就結束了,整個流程圖如下所示:
在這裏插入圖片描述
Windows下主要通過IOCP來向系統內核發送I/O調用和從內核獲取已完成的I/O操作,加以事件循環,以此完成異步I/O的過程。在Linux下通過epoll實現這個過程,FreeBSD下通過kqueue實現,Solaris下通過Event ports實現。不同的是線程池在Windows下由內核(IOCP)直接提供,*nix系列下由libuv自行實現。

非 I/O 的異步 API

Node中還存在一些和I/O無關的異步API,它們分別是setTimeout()、setInterval()、setImmediate()和process.nextTick()。

  1. 定時器
  2. process.nextTick()
  3. setImmediate()

定時器

setTimeout()和setInterval()與瀏覽器中的API是一致的,它們的實現原理與異步I/O比較類似,只是不需要I/O線程池的參與調用setTimeout()或者setInterval()創建的定時器會被插入到定時器觀察者內部的一個紅黑樹中。每次Tick執行時,會從該紅黑樹中迭代取出定時器對象,檢查是否超過定時時間,如果超過,就形成一個事件,它的回調函數將立即執行

定時器的問題在於,它不是精確的,不過是在容忍範圍內。

通過下面一張經典圖來看下setTimeOut行爲:
在這裏插入圖片描述
總結:

  1. 實現原理與異步I/O比較類似,只是不需要I/O線程池的參與
  2. 定時器的問題在於,它不是精確的
  • process.nextTick()

process.nextTick()表示立即異步執行一個任務,一般情況下我們會通過setTimeout調用,來達到需要的效果,但是定時器是不精確的,採用定時器需要動用紅黑樹,創建定時器對象和迭代等操作,setTimeout(fn, 0)的方式比較浪費性能,相比較之下process.nextTick()方法更爲輕量。

每次調用process.nextTick()方法,只會將回調函數放入隊列中,在下一輪Tick時取出執行。定時器中採用紅黑樹的操作時間複雜度爲O(lg(n)),nextTick()的時間複雜度爲O(1)。所以,process.nextTick()更高效。

總結:

  1. setTimeout(fn, 0)的方式比較浪費性能,process.nextTick()方法更爲輕量
  2. 每次調用process.nextTick()方法時,只會將回調函數放入隊列中,在下一輪Tick時取出執行
  • setImmediate()

setImmediate()方法與process.nextTick()方法類似,都是將回調函數延遲執行。那他們之間有什麼區別呢?我們先來看下一段代碼:

process.nextTick(function () { 
 console.log('nextTick延遲執行'); 
}); 
setImmediate(function () { 
 console.log('setImmediate延遲執行'); 
}); 
console.log('正常執行'); 
// 其執行結果如下:
// 正常執行
// nextTick延遲執行
// setImmediate延遲執行

process.nextTick()中的回調函數執行的優先級要高於setImmediate()。這裏的原因在於事件循環對觀察者的檢查是有先後順序的,process.nextTick()屬於idle觀察者,setImmediate()屬於check觀察者。在每一個輪循環檢查中,idle觀察者先於I/O觀察者,I/O觀察者先於check觀察者

具體的實現上,process.nextTick()的回調函數保存在一個數組中,setImmediate()的結果則是保存在鏈表中。在行爲上,process.nextTick()在每輪循環中會將數組中的回調函數全部執行完,而setImmediate()在每輪循環中執行鏈表中的一個回調函數。

總結:

  1. process.nextTick()中的回調函數執行的優先級要高於setImmediate()
  2. 原因在於事件循環對觀察者的檢查是有先後順序的

事件驅動與高性能服務器

  1. 事件驅動的實質是通過主循環加事件觸發的方式來運行程序
  2. 事件循環是異步實現的核心,它與瀏覽器中的執行模型基本保持了一致

node的異步I/O基本上就是上面這些,歡迎大家批評改正。

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