Node 最初是為打造高效能的Web 伺服器而生,作為JavaScript 的服務端運作時,具有事件驅動、非同步I/O、單執行緒等功能。基於事件循環的非同步程式設計模型讓Node 具備處理高並發的能力,大幅提升伺服器的效能,同時,由於維持了JavaScript 單執行緒的特點,Node 不需要處理多執行緒下狀態同步、死鎖等問題,也沒有線程上下文切換所帶來的效能上的開銷。基於這些特性,讓Node 具備高效能、高並發的先天優勢,可基於它建構各種高速、可伸縮網路應用平台。
本文將深入Node 非同步和事件循環的底層實作和執行機制,希望對你有幫助。
Node 為什麼要使用非同步來作為核心程式設計模型呢?
前面說過,Node 最初是為打造高效能的Web 伺服器而生,假設業務場景中有幾組互不相關的任務要完成,現代主流的解決方式有以下兩種:單執行緒串列依序
執行。
多執行緒並行完成。
單執行緒串列依序執行,是一種同步的程式設計模型,它雖然比較符合程式設計師依序思考的思考方式,易寫出更順手的程式碼,但由於是同步執行I/O,同一時刻只能處理單一請求,會導致伺服器回應速度較慢,無法在高並發的應用程式場景下適用,且由於是阻塞I/O,CPU 會一直等待I/O 完成,無法做其他事情,使CPU 的處理能力得不到充分利用,最終導致效率的低下,
而多執行緒的程式設計模型也會因為程式設計中的狀態同步、死鎖等問題讓開發人員頭痛。儘管多執行緒在多核心CPU 上能夠有效提升CPU 的使用率。
雖然單執行緒串列依序執行和多執行緒並行完成的程式設計模型有其自身的優勢,但是在效能、開發難度等方面也有不足之處。
除此之外,從回應客戶端請求的速度出發,如果客戶端同時取得兩個資源,同步方式的回應速度會是兩個資源的回應速度總和,而非同步方式的回應速度會是兩者中最大的一個,效能優勢相比同步十分明顯。隨著應用程式複雜度的增加,該場景會演變成同時回應n 個請求,非同步相比於同步的優勢將會凸顯出來。
綜上所述,Node 給出了它的答案:利用單線程,遠離多線程死鎖、狀態同步等問題;利用非同步I/O,讓單線程遠離阻塞,以便更好地使用CPU。這就是Node 使用非同步作為核心程式設計模型的原因。
此外,為了彌補單執行緒無法利用多核心CPU 的缺點,Node 也提供了類似瀏覽器中Web Workers 的子進程,該子進程可以透過工作進程高效地利用CPU。
聊完了為什麼要使用異步,那又要如何實現異步呢?
我們通常所說的非同步操作總共有兩類:一是像檔案I/O、網路I/O 這類與I/O 有關的操作;二是像setTimeOut
、 setInterval
這類與I/O 無關的操作。很明顯我們所討論的非同步是指與I/O 有關的操作,即非同步I/O。
非同步I/O 的提出是期望I/O 的呼叫不會阻塞後續程式的執行,將原有等待I/O 完成的這段時間分配給其餘需要的業務去執行。要達到這個目的,就需要用到非阻塞I/O。
阻塞I/O 是CPU 在發起I/O 呼叫後,會一直阻塞,等待I/O 完成。知道了阻塞I/O,非阻塞I/O 就很好理解了,CPU 在發起I/O 呼叫後會立即返回,而不是阻塞等待,在I/O 完成之前,CPU 可以處理其他交易。顯然,相較於阻塞I/O,非阻塞I/O 多於效能的提升是很明顯的。
那麼,既然使用了非阻塞I/O,CPU 在發起I/O 呼叫後可以立即返回,那它是如何知道I/O 完成的呢?答案是輪詢。
為了及時取得I/O 呼叫的狀態,CPU 會不斷重複呼叫I/O 操作來確認I/O 是否已經完成,而這種重複呼叫判斷操作是否完成的技術就稱為輪詢。
顯然,輪詢會讓CPU 不斷重複執行狀態判斷,是對CPU 資源的浪費。而且,輪詢的間隔很難控制,如果間隔太長,I/O 操作的完成得不到及時的回應,間接降低應用程式的回應速度;如果間隔太短,難免會讓CPU 花在輪詢的耗時變長,降低CPU 資源的使用率。
因此,輪詢雖然滿足了非阻塞I/O 不會阻塞後續程式的執行的要求,但是對於應用程式而言,它仍然只能算是一種同步,因為應用程式仍然需要等待I/O 完全返回,依舊花了很多時間等待。
我們所期望的完美的非同步I/O,應該是應用程式發起非阻塞調用,無須透過輪詢的方式不斷查詢I/O 調用的狀態,而是可以直接處理下一個任務,在I/O 完成後透過信號量或回調將資料傳遞給應用程式即可。
如何實現這種非同步I/O 呢?答案是線程池。
雖然本文一直提到,Node 是單執行緒執行的,但此處的單執行緒是指JavaScript 程式碼是執行在單執行緒上的,對於I/O 操作這類與主業務邏輯無關的部分,透過運行在其他執行緒的方式實現,並不會影響或阻塞主執行緒的運行,反而可以提高主執行緒的執行效率,實現非同步I/O。
透過線程池,讓主線程僅進行I/O 的調用,讓其他多個線程進行阻塞I/O 或非阻塞I/O 加輪詢技術完成數據獲取,再通過線程之間的通信將I/O得到的資料進行傳遞,這就輕鬆實現了非同步I/O:
主線程進行I/O 調用,而線程池進行I/O 操作,完成數據的獲取,然後通過線程之間的通信將數據傳遞給主線程,即可完成一次I/O 的調用,主線程再利用回呼函數,將資料暴露給用戶,用戶再利用這些資料來完成業務邏輯層面的操作,這就是Node 中一次完整的非同步I/O 流程。而對於使用者來說,不必在意底層這些繁瑣的實作細節,只需要呼叫Node 封裝好的非同步API,並傳入處理業務邏輯的回呼函數即可,如下所示:
const fs = require("fs") ; fs.readFile('example.js', (data) => { // 進行業務邏輯的處理});
Nodejs 的非同步底層實作機制在不同平台下有所不同:Windows 下主要透過IOCP 來向系統核心發送I/O 呼叫和從核心取得已完成的I/O 操作,配以事件循環,以此完成非同步I/O 的過程;Linux 下透過epoll 實現這個過程;FreeBSD下透過kqueue 實現,Solaris 下透過Event ports 實現。執行緒池在Windows 下由核心(IOCP)直接提供, *nix
系列則由libuv 自行實作。
由於Windows 平台和*nix
平台的差異,Node 提供了libuv 作為抽象封裝層,使得所有平台相容性的判斷都由這一層來完成,保證上層的Node 與下層的自訂線程池及IOCP 之間各自獨立。 Node 在編譯期間會判斷平台條件,選擇性編譯unix 目錄或是win 目錄下的原始檔到目標程式中:
以上就是Node 對非同步的實作。
(線程池的大小可以透過環境變數UV_THREADPOOL_SIZE
設置,預設值為4,用戶可結合實際情況來調整這個值的大小。)
那麼問題來了,在得到線程池傳遞過來的資料後,主線程是如何、何時呼叫回調函數的呢?答案是事件循環。
既然使用回呼函數來進行I/O 資料的處理,就必然涉及到何時、如何呼叫回呼函數的問題。在實際開發中,往往會涉及多個、多類異步I/O 調用的場景,如何合理安排這些異步I/O 回調的調用,確保異步回調的有序進行是一個難題,而且,除了異步I /O 之外,還存在定時器這類非I/O 的非同步調用,這類API 即時性強,優先權相應地更高,如何實現不同優先權回調地調度呢?
因此,必須存在一個調度機制,對不同優先順序、不同類型的非同步任務進行協調,確保這些任務在主執行緒上有條不紊地運作。與瀏覽器一樣,Node 選擇了事件循環來承擔這項重任。
Node 根據任務的種類和優先順序將它們分為七類:Timers、Pending、Idle、Prepare、Poll、Check、Close。對於每類任務,都存在一個先進先出的任務佇列來存放任務及其回呼(Timers 是用小頂堆存放)。基於這七個類型,Node 將事件循環的執行分為以下七個階段:
這個階段的執行優先權是最高的。
事件循環在這個階段會檢查存放定時器的資料結構(最小堆),對其中的定時器進行遍歷,逐個比較當前時間和過期時間,判斷該定時器是否過期,如果過期的話,就將該定時器的回調函數取出並執行。
此階段會執行網路、IO 等異常時的回呼。一些*nix
上報的錯誤,在這個階段會被處理。另外,一些應該在上輪循環的poll 階段執行的I/O 回調會被推遲到這個階段執行。
這兩個階段僅在事件循環內部使用。
檢索新的I/O 事件;執行與I/O 相關的回調(除了關閉回呼、定時器調度的回調和之外幾乎所有回調setImmediate()
);節點會在適當的時候阻塞在這裡。
poll,即輪詢階段是事件循環最重要的階段,網路I/O、檔案I/O 的回呼都主要在這個階段被處理。此階段有兩個主要功能:
計算該階段應該阻塞和輪詢I/O 的時間。
處理I/O 佇列中的回呼。
當事件循環進入poll 階段並且沒有設定計時器時:
如果輪詢隊列不為空,則事件循環將遍歷該隊列,同步地執行它們,直到隊列為空或達到可執行的最大數量。
如果輪詢隊列為空,則會發生另外兩種情況之一:
如果有setImmediate()
回呼需要執行,則立即結束poll 階段,並進入check 階段以執行回呼。
如果沒有setImmediate()
回呼需要執行,事件循環將停留在該階段以等待回呼被加入到佇列中,然後立即執行它們。在超時時間到達前,事件循環會一直停留等待。之所以選擇停留在這裡是因為Node 主要是處理IO 的,這樣可以更及時地回應IO。
一旦輪詢隊列為空,事件循環將檢查已達到時間閾值的計時器。如果有一個或多個定時器達到時間閾值,事件循環將回到timers 階段以執行這些定時器的回調。
該階段會依序執行setImmediate()
的回呼。
此階段會執行一些關閉資源的回調,如socket.on('close', ...)
。此階段延誤執行也影響不大,優先順序最低。
當Node 程序啟動時,它會初始化事件循環,執行使用者的輸入代碼,進行對應非同步API 的呼叫、計時器的調度等等,然後開始進入事件循環:
┌─────────── ────────────────┐ ┌─>│ timers │ │ └──────────────┬─────────────┘ │ ┌──────────────┴──────────────┐ │ │ pending callbacks │ │ └──────────────┬─────────────┘ │ ┌──────────────┴──────────────┐ │ │ idle, prepare │ │ └──────────────┬─────────────┘ ┌───────────────┐ │ ┌──────────────┴─────────────┐ │ incoming: │ │ │ poll │<─────┤ connections, │ │ └──────────────┬─────────────┘ │ data, etc. │ │ ┌──────────────┴─────────────┐ └───────────────┘ │ │ check │ │ └──────────────┬─────────────┘ │ ┌──────────────┴──────────────┐ └──┤ close callbacks │ └──────────────────────────┘
事件循環的每一輪循環(通常被稱為tick),會按照如上給定的優先順序進入七個階段的執行,每個階段會執行一定數量的隊列中的回調,之所以只執行一定數量而不全部執行完,是為了防止當前階段執行時間過長,避免下一個階段得不到執行。
OK,以上就是事件循環的基本執行流程。現在讓我們來看另一個問題。
對於以下這個場景:
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
當服務成功綁定到8000 端口,即listen()
成功調用時,此時listening
事件的回調還沒有綁定,因此端口成功綁定後,我們所傳入的listening
事件的回呼並不會執行。
再思考另一個問題,我們在開發中可能會有一些需求,如處理錯誤、清理不需要的資源等等優先級不是那麼高的任務,如果以同步的方式執行這些邏輯,就會影響當前任務的執行效率;如果以非同步的方式,例如以回呼的形式傳入setImmediate()
又無法保證它們的執行時機,即時性不高。那麼要如何處理這些邏輯呢?
基於這幾個問題,Node 參考了瀏覽器,也實作了一套微任務的機制。在Node 中,除了呼叫new Promise().then()
所傳入的回呼函數會被封裝成微任務外, process.nextTick()
的回呼也會被封裝成微任務,而後者的執行優先權比前者高。
有了微任務後,事件循環的執行流程又是怎麼樣的呢?換句話說,微任務的執行時機在什麼時候?
在node 11 及11 之後的版本,一旦執行完一個階段裡的一個任務就立刻執行微任務佇列,清空該佇列。
在node11 之前執行完一個階段後才開始執行微任務。
因此,有了微任務後,事件循環的每一輪循環,會先執行timers 階段的一個任務,然後按照先後順序清空process.nextTick()
和new Promise().then()
的微任務隊列,接著繼續執行timers 階段的下一個任務或下一個階段,即pending 階段的任務,依照這樣的順序以此類推。
利用process.nextTick()
,Node 就可以解決上面的連接埠綁定問題:在listen()
方法內部, listening
事件的發出會被封裝成回呼傳入process.nextTick()
中,如以下偽代碼所示:
function listen() { // 進行監聽埠的操作... // 將`listening` 事件的發出封裝成回呼傳入`process.nextTick()` process.nextTick(() => { emit('listening'); }); };
在目前程式碼執行完畢後便會開始執行微任務,從而發出listening
事件,觸發該事件回呼的呼叫。
由於非同步本身的不可預知性和複雜性,在使用Node 提供的非同步API 的過程中,儘管我們已經掌握了事件循環的執行原理,但是仍可能會有一些不符合直覺或預期的現象產生。
例如定時器( setTimeout
、 setImmediate
)的執行順序會因為呼叫它們的上下文而有所不同。如果兩者都是從頂層上下文中呼叫的,那麼它們的執行時間取決於進程或機器的效能。
讓我們來看以下這個範例:
setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); });
以上程式碼的執行結果是什麼?根據我們剛才對事件循環的描述,你可能會有這樣的答案:由於timers 階段會比check 階段先執行,因此setTimeout()
的回調會先執行,然後再執行setImmediate()
的回呼。
實際上,這段程式碼的輸出結果是不確定的,可能先輸出timeout,也可能先輸出immediate。這是因為這兩個定時器都是在全域上下文中呼叫的,當事件循環開始運行並執行到timers 階段時,當前時間可能大於1 ms,也可能不足1 ms,取決於機器的執行性能,因此setTimeout()
在第一個timers 階段是否會被執行實際上是不確定的,因此才會出現不同的輸出結果。
(當delay
( setTimeout
的第二個參數)的值大於2147483647
或小於1
時, delay
會被設定為1
。)
我們接著看下面這段程式碼:
const fs = require('fs'); fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); }); });
可以看到,在這段程式碼中兩個計時器都被封裝成回呼函數傳入readFile
中,很明顯當該回呼被呼叫時當前時間肯定大於1 ms 了,所以setTimeout
的回呼會比setImmediate
的回調先得到調用,因此列印結果為: timeout immediate
。
以上是使用Node 時需要注意的與定時器相關的事項。除此之外,還要注意process.nextTick()
與new Promise().then()
還有setImmediate()
的執行順序,由於這部分比較簡單,前面已經提到過,就不再贅述了。
文章開頭從為什麼要非同步、如何實現非同步兩個角度出發,較詳細地闡述了Node 事件循環的實現原理,並提到一些需要注意的相關事項,希望對你有所幫助。