- 新時期的Node.js入門
- 李鍇
- 4246字
- 2019-12-12 17:05:39
1.3 事件循環(Event loop)
關于Event loop網絡上已經有了很多的介紹,不少開發者即使已經有了JavaScript編程的經驗,卻仍然不能很好地理解事件循環的概念。不過通俗地來講,事件循環就是一個程序啟動期間運行的死循環,沒有任何特別之處。
Node代碼雖然運行在單線程中,但仍然能支持高并發,就是依靠事件循環實現的。
1.3.1 事件與循環
首先我們要回答兩個問題:
- 什么是事件
- 什么在循環
1.事件
在可交互的用戶頁面上,用戶會產生一系列的事件,包括單擊按鈕、拖動元素等,這些事件會按照順序被加載到一個隊列中去。除了頁面事件之外,還有一些例如Ajax執行成功、文件讀取完畢等事件。
2.循環
在GUI程序中,代碼本身就處在一個循環的包裹中,例如用Java Swing開發桌面程序,就要啟動一個JFrame,還要調用run方法,而run方法內部就包括了一個循環,該循環位于主線程上。
這個循環通常對開發者來說是不可見的,只有當開發者單擊了窗體的關閉按鈕,該循環才會結束。當用戶單擊了頁面上的按鈕或者進行其他操作時,就會產生相應的事件,這些事件會被加入到一個隊列中,然后主循環會逐個處理它們。
JavaScript也是一樣,用戶在前臺不斷產生事件,背后的循環(由瀏覽器實現)會逐個地處理他們。
而JavaScript是單線程的,為了避免一個過于耗時的操作阻塞了其他操作的執行,就要通過異步加回調的方式解決問題。
以Ajax請求為例,當JavaScript執行到對應的代碼時,就為這句代碼注冊了一個事件,在發出請求后該語句就執行完畢了,后續的操作會交給回調函數來處理。
此時,瀏覽器背后的循環正在不斷遍歷事件隊列,在Ajax操作完成之前,事件隊列里還是空的(并不是發出請求這一動作被加入事件隊列,而是請求完成這一事件才會加入隊列)。
如果Ajax操作完成了,這個隊列中就會增加一個事件,隨后被循環遍歷到,如果這個事件綁定了一個回調方法,那么循環就會去調用這個方法。
1.3.2 Node中的事件循環
Node中的事件循環比起瀏覽器中的JavaScript還是有一些區別的,各個瀏覽器在底層的實現上可能有些細微的出入;而Node只有一種實現,相對起來就少了一些理解上的麻煩。
首先要明確的是,事件循環同樣運行在單線程環境下,JavaScript的事件循環是依靠瀏覽器實現的,而Node作為另一種運行時,事件循環由底層的libuv實現。
下面如圖1-4所示描述了Node中事件循環的具體流程。

圖1-4
上面的圖例中,將事件循環分成了6個不同的階段,其中每個階段都維護著一個回調函數的隊列,在不同的階段,事件循環會處理不同類型的事件,其代表的含義分別為:
- Timers:用來處理setTimeOut()和setInterval()的回調。
- I/O callbacks:大多數的回調方法在這個階段執行,除了timers、 close和setImmediate事件的回調(所謂的“大多數”,我們會在后面解釋)。
- idle, prepare:僅僅在內部使用,我們不管它。
- Poll:輪詢,不斷檢查有沒有新的IO事件,事件循環可能會在這里阻塞(也會在后面介紹)。
- Check:處理setImmediate()事件的回調。
- close callbacks:處理一些close相關的事件,例如socket.on('close', ...)。
注意:我們上面使用“階段”(Phase)來描述事件循環,它并沒有任何特別之處,本質上就是不同方法的順序調用,用代碼描述一下大約就是這種結構:

上面代碼中的每一個方法即代表一個“階段”。
假設事件循環現在進入了某個階段(即開始執行上面其中一個方法),即使在這期間有其他隊列中的事件就緒,也會先將當前階段隊列里的全部回調方法執行完畢后,再進入到下個階段,結合代碼這也是易于理解的。
接下來我們針對每個階段進行詳細說明。
1.timers
從名字就可以看出來,這個階段主要用來處理定時器相關的回調,當一個定時器超時后,一個事件就會加入到隊列中,事件循環會跳轉至這個階段執行對應的回調函數。
定時器的回調會在觸發后盡可能早(as early as they can)地被調用,這表示實際的延時可能會比定時器規定的時間要長。
如果事件循環,此時正在執行一個比較耗時的callback,例如處理一個比較耗時的循環,那么定時器的回調只能等當前回調執行結束了才能被執行,即被阻塞。事實上,timers階段的執行受到poll階段控制。
2.IO callbacks階段
官方文檔對這個階段的描述為除了timers、 setImmediate,以及close操作之外的大多數的回調方法都位于這個階段執行。事實上從源碼來看,該階段只是用來執行pending callback,例如一個TCP socket執行出現了錯誤,在一些*nix系統下可能希望稍后再處理這里的錯誤,那么這個回調就會放在IO callback階段來執行。
一些常見的回調,例如fs.readFile的回調是放在poll階段來執行的。
3.poll階段
poll階段的主要任務是等待新的事件出現(該階段使用epoll來獲取新的事件),如果沒有,事件循環可能會在此阻塞(關于是否在poll階段阻塞以及阻塞多長時間,libuv有一些復雜的判定方法,這里不作深究,如果讀者有興趣,可以參考libuv源碼文件src/unix/core.c下的uv_run方法,該方法是事件循環的核心方法)。
這些事件對應的回調方法可能位于timers階段(如果定義了定時器),也可能是check階段(設置了setImmediate方法)。
Poll階段主要有兩個步驟如下:
(1)如果有到期的定時器,那么就執行定時器的回調方法。
(2)處理poll階段對應的事件隊列(以下簡稱poll隊列)里的事件。
當事件循環到達poll階段時,如果這時沒有要處理的定時器的回調方法,則會進行下面的判斷:
(1)如果poll隊列不為空,則事件循環會按照順序遍歷執行隊列中的回調函數,這個過程是同步的。
(2)如果poll隊列為空,會接著進行如下判斷:
- 如果當前代碼定義了setImmediate方法,事件循環會離開poll階段,然后進入check階段去執行setImmediate方法定義的回調方法。
- 如果當前代碼沒有定義setImmediate方法,那么事件循環可能會進入等待狀態,并等待新的事件出現,這也是該階段為什么會被命名為poll(輪詢)的原因。此外,還會不斷檢查是否有相關的定時器超時,如果有,就會跳轉到timers階段,然后執行對應的回調。
4.check階段
setImmediate是一個特殊的定時器方法,它占據了事件循環的一個階段,整個check階段就是為setImmediate方法而設置的。
一般情況下,當事件循環到達poll階段后,就會檢查當前代碼是否調用了setImmediate,但如果一個回調函數是被setImmediate方法調用的,事件循環就會跳出poll階段進而進入check階段。
5.close階段
如果一個socket或者一個句柄被關閉,那么就會產生一個close事件,該事件會被加入到對應的隊列中。close階段執行完畢后,本輪事件循環結束,循環進入到下一輪。
看完了上面的描述,我們明白了Node中的事件循環是分階段處理的,對于每一階段來說,處理事件隊列中的事件就是執行對應的回調方法,每一階段的事件循環都對應著不同的隊列。
在Node中,事件隊列不止一個,定時器相關的事件和磁盤IO產生的事件需要不同的處理方式,如果把所有的事件都放到一個隊列里,勢必要增加許多類似switch/case的代碼;那樣的話倒不如將不同類型的事件歸類到不同的事件隊列里,然后一層層地遍歷下來,如果當中出現了新的事件,就進行相應的處理。
為了更好地理解Node中的事件循環,以一段代碼為例來配合說明:

上面代碼改編自官方文檔中的一個例子,講述事件循環不同過程的處理步驟。這段代碼的邏輯很簡單,包含了readfile和timer兩個異步操作。
我們來觀察這段代碼的執行過程,代碼開始運行后,事件循環也開始運作了。
首先檢查timers,然而timers對應的事件隊列目前還為空(100ms后才會有事件產生),事件循環向后執行到了poll階段,到目前為止還沒有事件出現,由于代碼中沒有定義setImmediate操作,事件循環便在此一直等待新的事件出現。
直到95ms后(假設readFile耗費的時間為95ms,實際上可能比這個時間長或短一些),readFile讀取文件完畢,產生了一個事件,加入到了poll這一隊列中,此時事件循環將該隊列中的事件取出,準備執行之后的callback(此時err和data的值已經就緒),readFile的回調方法什么都沒做,只是暫停了10ms。
事件循環本身也被阻塞10ms,按照通常的思維,95ms+10ms=105ms>100ms,timers隊列中的事件已經就緒,應該先執行對應的回調方法才是,然而由于事件循環也是單線程運行的,因此也會停止10ms,如果readFile的回調函數中包含了一個死循環,那么整個事件循環都會被阻塞,setTimeout的回調永遠不會執行。
readFile的回調完成后,事件循環切換到timers階段,接著取出timers隊列中的事件執行對應的回調方法。
如果讀者想了解更多關于事件循環的內容,也可以參考libuv文檔中針對事件循環不同階段的處理方式(http://docs.libuv.org/en/v1.x/design.html#the-i-o-loop)。
講完了事件循環,我們回過頭來看1.2節最后的那句話。
“除了你的代碼,一切都是并行的”

試著回答幾個問題:
- 如果存在并行,那么應該位于Node的哪個層面?
- 是事件循環提供了并行的能力嗎?
- 如果你的計算機只有一個單核的CPU(暫先不考慮超線程技術,即在一個CPU上同時執行兩個線程),還能做到并行嗎?
第三個問題是最容易答的,當你只有一個單核CPU時,就算把代碼寫出花來也不能獲得真正的并行。
第二個問題,事件循環運行在單線程環境中,這表示一個時刻只能處理一個事件,沒法提供并行支持。
那么回到第一個問題,如果真的存在并行,那么只能存在于libuv的線程池中,實現的并行為線程級別的并行(需要多核CPU)。
1.3.3 process.nextTick
process.nextTick的意思就是定義出一個異步動作,并且讓這個動作在事件循環當前階段結束后執行。
例如下面的代碼,將打印first的操作放在nextTick的回調中執行,最后先打印出next,再打印first。

process.nextTick其實并不是事件循環的一部分,但它的回調方法也是由事件循環調用的,該方法定義的回調方法會被加入到名為nextTickQueue的隊列中。在事件循環的任何階段,如果nextTickQueue不為空,都會在當前階段操作結束后優先執行nextTickQueue中的回調函數,當nextTickQueue中的回調方法被執行完畢后,事件循環才會繼續向下執行。
Node限制了nextTickQueue的大小,如果遞歸調用了process.nextTick,那么當nextTickQueue達到最大限制后會拋出一個錯誤,我們可以寫一段代碼來證實這一點。

運行上面的代碼,馬上就會出現:

的錯誤。
既然nextTickQueue也是一個隊列,那么先被加入隊列的回調會先執行,我們可以定義多個process.nextTick,然后觀察他們的執行順序:

和其他回調函數一樣,nextTick定義的回調也是由事件循環執行的,如果nextTick的回調方法中出現了阻塞操作,后面的要執行的回調同樣會被阻塞。

1.nextTick與setImmediate
setImmediate方法不屬于ECMAScript標準,而是Node提出的新方法,它同樣將一個回調函數加入到事件隊列中,不同于setTimeout和setInterval,setImmediate并不接受一個時間作為參數,setImmediate的事件會在當前事件循環的結尾觸發,對應的回調方法會在當前事件循環末尾(check階段)執行。
setImmediate方法和process.nextTick方法很相似,二者經常被拿來放在一起比較,由于process.nextTick會在當前操作完成后立刻執行,因此總會在setImmediate之前執行。
關于這兩個方法有個笑話:nextTick和setImmediate的行為和名稱含義是反過來的。

上面的代碼總是會輸出:

此外,當有遞歸的異步操作時只能使用setImmediate,不能使用process.nextTick,前面已經展示過了遞歸調用nextTick會出現的錯誤,下面使用setImmediate來試試看:

完全沒問題!這是因為setImmediate不會生成call stack。
2.setImmediate和 setTimeout
通過上面的內容,我們已經知道了setImmediate方法會在poll階段結束后執行,而setTimeout會在規定的時間到期后執行,由于無法預測執行代碼時事件循環當前處于哪個階段,因此當代碼中同時存在這兩個方法時,回調函數的執行順序不是固定的。

但如果將二者放在一個IO操作的callback中,則永遠是setImmediate先執行:

這是因為readFile的回調執行時,事件循環位于poll階段,因此事件循環會先進入check階段執行setImmediate的回調,然后再進入timers階段執行setTimeout的回調。
- Beginning Java Data Structures and Algorithms
- R語言游戲數據分析與挖掘
- Web Application Development with R Using Shiny(Second Edition)
- NumPy Essentials
- Python編程完全入門教程
- Android 應用案例開發大全(第3版)
- Hands-On Reinforcement Learning with Python
- Visual FoxPro程序設計
- 區塊鏈技術進階與實戰(第2版)
- 軟件測試教程
- R數據科學實戰:工具詳解與案例分析
- Arduino機器人系統設計及開發
- Modernizing Legacy Applications in PHP
- Hacking Android
- Solr權威指南(下卷)