NodeJS Design Patterns #2

非同步程式如何運作?(下)

從事件迴圈認識反應器模式

Kevin Cheng
Cow Say

--

上一篇文章我們討論了事件迴圈(Event Loop)底層所運用的設計模式 — — 反應器模式(The Reactor Pattern),反應器模式描述了事件迴圈和應用程式的關係。

在這一篇文章我們將繼續深入事件迴圈的實作細節。如果你還不了解反應器模式也沒關係,這不妨礙你理解這篇文章,若有興趣的話當然也歡迎回頭參閱上一篇文章。

在這篇文章中,主要會說明:

  1. 事件迴圈如何工作
  2. 如何在事件迴圈上註冊回調方法

事件迴圈如何工作?

理解了反應器模式之後,我們就可以更深入地說明 NodeJS 事件迴圈如何工作。

在 NodeJS 的文件中提供了以下這個流程圖。


┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘

資料來源

依序介紹一下每個階段的功能:

  1. Timers: setTimeoutsetInterval 的回調方法
  2. Pending callbacks:處理系統錯誤(如 ECONNREFUSED)的回調方法
  3. Idle, prepare:內部使用
  4. Poll:I/O 操作的回調方法
  5. Check: setImmediate 的回調方法
  6. Close callbacks:監聽 I/O 資源 close 事件的回調方法

上圖中的每一個區塊都是事件迴圈的一個階段(Phase),每個階段都有一個先進先出(FIFO)的佇列(Queue),佇列裡裝有該階段要執行的回調方法,每個階段會從佇列裡取出回調方法執行,持續執行到佇列空了或是達到回調執行最大數量上限為止,才會離開當前階段。

Timers

在此階段值得注意的是,在 setTimeoutsetInterval 所給定的時間指的是閾值(Threshold)而非定時的概念,也就是說一個回調方法被執行的時間點距離它被註冊的時間最少會是給定的時間,但也有可能會因系統繁忙而延誤、拖得更久。

Poll

此階段是事件迴圈最主要停留的階段。在事件迴圈進入 Poll 階段時,若 Timers 佇列或是 Check 佇列有回調,則事件迴圈會在處理完 Poll 佇列回調之後,繼續執行 Check 階段和 Timers 階段;若該二佇列都沒有回調,則 Poll 佇列執行完畢後,事件迴圈仍會在 Poll 階段停留一段時間,等待新的 I/O 事件一進來便手刀處理。

順帶一提,上述判斷 Timers 佇列是否有回調的定義是,是否有計時器已經倒數完畢有回調方法可供執行。

Check

當 Poll 階段閒置之後,若 Check 佇列有回調,事件迴圈便會不再等待 I/O 事件、進入這個階段執行。

Close Callbacks

當 I/O 資源遭遇無預期關閉,則會在這個階段執行 close 事件回調。特別強調無預期是因為若主動呼叫 socket.close()close 事件並不一定會發生在 Close callbacks 階段,而是視呼叫所在的階段而定,發佈 close 事件的動作會透過 process.nextTick() 執行。可參考這篇 SOF

nextTick 和 microtask

除了以上幾個階段和它們的佇列之外,另外還有兩個佇列值得一提:分別是 nextTick 佇列和 microtask 佇列。這兩個佇列分別會在第一次進入 Timers 階段之前每一個階段之間執行,執行的順序是先 nextTick 再來 microtask。

這兩種佇列有一個特色,它們執行的時候沒有回調呼叫數量上限,因此如果在回調執行期間又註冊了新的回調到同一個佇列上,則這個新回調也會一併在這一次佇列處理期間執行。最明顯的例子便是在 process.nextTick() 中遞迴呼叫 process.nextTick() ,這會造成事件迴圈永遠在處理 nextTick 佇列,造成事件迴圈阻塞。

如何在事件迴圈上註冊回調方法?

  1. setTimeout(cb, delay):將 cb 註冊至 Timers 階段,且至少延遲 delay 毫秒後執行。與前端不同的是,在 NodeJS 裡 delay 的預設值是 1。值得注意的是在 Timers 階段遞迴呼叫 setTimeout 註冊的回調方法並不會在當前這個階段執行,因此不必擔心阻塞事件迴圈導致 I/O 飢餓的問題。
  2. setInterval(cb, period):將 cb 註冊至 Timers 階段,且間隔至少 period 毫秒執行一次。與前端不同的是,在 NodeJS 裡 period 的預設值是 1。與 setTimeout 一樣遞迴呼叫不會阻塞事件迴圈。
  3. setImmediate(cb):將 cb 註冊至 Check 階段。遞迴呼叫註冊的回調會在下一次進入Check 階段才執行,因此不會阻塞事件迴圈。
  4. process.nextTick(cb):將 cb 註冊至 nextTick 佇列,於當前階段結束後執行。這個方法切莫遞迴呼叫!官方建議開發者優先使用 setImmediate(cb),除非你很確信 cb 一定要在當前階段結束後執行。比較常見的用途包括為了讓事件以非同步的方式發佈,或是為了使 API 統一為非同步傳遞結果(這個用途會在下一篇文章解釋)。
  5. NodeJS I/O:fs.readFile() 或是 http.Server.listen() 等 API。
  6. Promise.then(cb):cb 會於 Promise 實現(Resolve)的同時加入 microtask 佇列。與 process.nextTick(cb) 一樣也有機會造成事件迴圈阻塞,不過 Promise.then(cb) 的條件稍微嚴格一點,因為將 then 回調註冊到 microtask 佇列的時間點是在 Promise 實現的同時,也就是若我們在 cb 中同步使一個 Promise 實現,它的 then 回調才有可能即時被加入當前的 microtask 佇列中造成事件迴圈阻塞。

以下為 process.nextTick(cb) 造成事件迴圈阻塞的例子:

let stop = falsefunction recursiveNextTick (i) {
if (!stop) {
console.log(i)
process.nextTick(() => recursiveNextTick(i + 1)) // 遞迴呼叫
}
}
process.nextTick(() => recursiveNextTick(1))// 永遠不會執行
// 因為全域程式碼執行完畢就會先執行 nextTick 佇列而阻塞
// 永遠不會進入 Check 階段(連 Timers 階段都不會開始)
setImmediate(() => {
stop = true
console.log('stopped')
})

Promise.then(cb) 要造成阻塞的話,需要在 cb 執行期間使一個 Promise 同步實現,並且那個已經實現的 Promise 須擁有透過 then 函數註冊的回調,才可能無限遞迴地在 microtask 佇列上註冊新回調。

let stop = falsefunction recursiveThen (i) {
if (!stop) {
console.log(i)
return Promise.resolve(i + 1) // 已實現的 Promise
.then(recursiveThen)
}
}
Promise.resolve(1)
.then(recursiveThen)
// 永遠不會執行
// 因為全域程式碼執行完畢就會先執行 microtask 佇列而阻塞
// 永遠不會進入 Check 階段(連 Timers 階段都不會開始)
setImmediate(() => {
stop = true
console.log('stopped')
})

從這裡可以知道,只要能找到 Promise 實現的時機點,我們就可以知道下一個 then 的回調會在何時呼叫。有了這個認知之後,我們接著來看 Promise 的糖衣 — — async/await 。

async 以 Promise.resolve() 包裝非同步方法的返回值,而 await 則包裝了 Promise.then(cb)

以這段程式碼為例:

async function doSomethingAsync () {
// before
console.log(’before’)
let data = await readSomethingAsync()
// after
console.log(’after’)
}
doSomethingAsync()
.then(() => {
// final
console.log(’final’)
})

若我們將整個流程展開,可得到

function doSomething () {
return new Promise((resolve) => { // async
// before
console.log(’before’)
readSomethingAsync()
.then((data) => { // await
// after
console.log(’after’)
resolve() // 原 doSomethingAsync 沒有返回任何東西
})
})
}
doSomething()
.then(() => {
// final
console.log(’final’)
})

由於 Promise 建構子中的方法會在建構子返回之前就被呼叫,因此如果我們直接執行 doSomething() 則會以同步的方式執行註解區塊 before 和 readSomethingAsync

doSomething() 可以得知,在doSomethingAsync 的程式碼中,從賦值給 data 開始一直到註解區塊 after 則是屬於 await 所包裝的Promise.then(cb)cb 部分的等效程式碼。

通常readSomethingAsync 會在 Poll 階段收到讀取結果並同步實現 Promise 將結果傳遞上來,因此賦值給 data 已經是在 Poll 階段之後的 microtask 佇列中。也就是說,await 以後的程式碼會是在 Poll 階段之後的 microtask 佇列中執行。

另外,原方法 doSomethingAsync 因為 async 糖衣的關係,在返回的時候會經過 Promise.resolve() 的過程,也就是 Promise 實現,因此把註解區塊 final 所在回調註冊至 microtask 佇列中。由於 doSomethingAsync 實現的過程與註解區塊 after 處於相同 microtask 佇列中,則可推論 final 即為下一個 microtask 回調。

整理一下從這段拆解 async/await 的過程中,我們所得到的想法:

  1. await 以後的程式碼,會在它所等待的 Promise 實現時所處階段之後的 microtask 執行。
  2. async 方法返回的同時就是在實現一個 Promise,因此 microtask 上會有新回調註冊,這個新回調便是呼叫並等待此 async 方法的後續程式碼(如上面例子中的註解區塊 final)。

理解了上面這個例子之後我們就能做點延伸,來拆解 koajs 的 middleware 執行順序,每個程式區塊分別在哪個階段執行。

const Koa = require('koa')
const app = new Koa()
app.use(async (ctx, next) => {
// #1 Poll
await next()
// #9 microtask after Timers
})
app.use(async (ctx, next) => {
// #2 Poll
await new Promise((resolve) => {
// #3 Poll
//(提供給 Promise 建構子的方法會在建構子返回之前同步執行)
setImmediate(resolve)
})
// #4 microtask after Check, the same tick as Poll
await next()
// #8 microtask after Timers, unsure tick
})
app.use(async (ctx, next) => {
// #5 microtask after Check
await new Promise((resolve) => {
// #6 microtask after Check
setTimeout(resolve)
})
// #7 microtask after Timers
})

透過這樣的練習,或許我們就能更加掌握我們的響應何時能被寫出去,甚至去處理更複雜的執行順序問題,並有效避免競態條件(Race Condition)。

若有問題或是謬誤都歡迎與我反應,希望以上大家都有看懂:)

--

--

Kevin Cheng
Cow Say
Editor for

貓奴 / 後端工程師 / 人生最重要的四件事:溫柔、勇敢、自由、浪漫