NodeJS Design Patterns #1

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

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

Kevin Cheng
Cow Say

--

Yet another introduction to the Event Loop!

網路上不乏關於這個主題的文章,在這些相似的文章裡,我們可以看到大家最關注的事情莫過於如何在非同步的程式中與事件迴圈(Event Loop)互動 — — 將回調方法(Callback Function)註冊在事件迴圈上,使它在特定的時間被呼叫。

儘管已經有這麼多文章探討這方面的內容,免不了還是得親自消化一次才能成為自己的知識,而這也是我發表這篇文章的初衷。

這篇是關於我學習 NodeJS 設計模式的第一篇文章,定位上屬於個人理解之後所整理的筆記,學習材料主要是針對 Maria Casciaro 和 Luciano Mammino 於 2020 年 7月發表的 NodeJS Design Pattens(第三版)進行討論。

另外,雖然前後端都有事件迴圈,但實作細節上有所差異,因此在本系列文章中我們會針對後端 NodeJS 的情況進行討論。

在讀完這篇文章之後,你將知道:

  1. 什麼是非阻塞 I/O
  2. 什麼是反應器模式

什麼是非阻塞 I/O?

在談非阻塞式(Non-blocking)I/O 之前,先來談談阻塞式 I/O。

阻塞式 I/O

阻塞式 I/O 優點之一便是邏輯很直觀,因為邏輯順序與程式碼在檔案內的排序互相呼應,由於它在讀寫完成之前會造成執行緒阻塞,因此我們可以安心假設讀寫方法返回後 I/O 操作即完成。

let data = socket.read()// 檔案讀取完成,data 已經就緒!
console.log(data)

造成執行緒阻塞同時也是它的缺點,因為在阻塞期間程式將無法對其他任務進行反應,舉個例子,當這段程式碼是在讀取某個 HTTP 請求時,便無法接受其他請求,造成其他請求遭遇延遲甚至超時斷開。

為了同時能夠處理數個請求,傳統的解決方案使用執行緒池(Thread Pool)將每一個進入系統的請求託付給一個空閒的執行緒處理,並重複利用每一個執行緒 — — 原因在於執行緒主要的開銷在於生成和結束時

乍看好像解決了問題,事實上這樣的解決方案仍不算最理想的方案!

若從成本效益方面出發來考量這個解決方案,我們投入的成本是執行緒佔據的記憶體空間、執行緒上下文交換(Context Switching)造成的開銷等等。儘管我們提高了系統在流量高峰時平行處理的能力,但我們必須知道大部分的情況下流量並非平均分佈,大部分的時候系統都是閒置的,在閒置的期間每一個執行緒仍然佔據了系統的記憶體、消耗了 CPU 週期,如此一來系統形同空轉。

讓我們回到單執行緒的情況下阻塞的話題。這邊歸結一個更重要的原因,便是 I/O 操作所需時長的不穩定性,我們無法預期呼叫 socket.read() 當下客戶端是否已經送出請求、是否已經收到內容可供讀取,但我們卻率先投入時間等待,這段等待時間才是最根本的問題所在。

若我們可以避開這段無謂的等待,那麼讀寫的動作就不會造成如此可觀的阻塞時間。

非阻塞式 I/O

基於 I/O 等待時間的不穩定性,理想上我們應該要在 I/O 就緒、進入可讀可寫的狀態之後,才執行相對的動作,這便是非阻塞 I/O 的核心想法。

最基本的做法是輪詢(Polling),週期性反覆地詢問系統 I/O 是否就緒,若狀態呈現可讀/可寫,則我們可以立刻進入我們的應用邏輯。

while (!socket.isReadyToRead()) { /* 什麼也沒做,只能等待 */ }// 因為上面已經確認可讀狀態了,讀取將不會耗費太久的時間
let data = socket.read()

輪詢的缺點來自上面範例程式碼第一行的迴圈,迴圈期間程式會以極高的頻率詢問 I/O 狀態,且大部分的時候皆得到的相同的答案——「尚未就緒」——否則早就離開迴圈了。這樣的邏輯稱為忙迴圈(Busy Loop),不但造成高 CPU 使用率,而且除了離開迴圈前最後一個答案之外大部分的答案都是無效的,對系統資源而言是無謂的浪費。

為了知道 I/O 資源何時就緒,作業系統提供了一套新的方式——事件解多工器(Event Demultiplexer),又稱事件通知介面(Event Notification Interface)。解多工器的運作原理是這樣的:在我們所關注的 I/O 資源都尚未就緒的時候,程式邏輯會停在詢問資源狀態的方法上,一旦方法返回之後,我們可以從返回結果得知哪些 I/O 資源就緒了。

let toRead = [ socket1, socket2, socket3 ]
let toWrite = [ socket4, socket5, socket6 ]
while (toRead.length > 0 || toWrite.length > 0) {
let readyOnes = select(toRead, toWrite) // 等待任何 socket 就緒
let readables = readyOnes[0]
let writables = readyOnes[1]

for (let readSock of readables) {
let data = readSock.read()
// 讀取完成
}
for (let writeSock of writables) {
writeSock.write(’Hello’)
// 寫入完成
}
}

邏輯結構上來說與上述輪詢方式其實很像,但不同的是我們不再反覆詢問一樣的問題,而是等待方法返回即表示資源就緒。

另外,非阻塞式 I/O 並非永遠不會阻塞,而是我們把阻塞的行為從讀寫 I/O 的階段,提前到了等待 I/O 就緒的階段,並允許我們能夠同時等待多項 I/O 資源,使我們得以在處理 I/O 的期間不會出現阻塞的現象。甚至我們可以提供超時(Timeout)的限制,使等待期間不會無限阻塞下去,將控制權返回程式碼中對其他任務進行處理。

上面範例中的 select 方法是在致敬 Linux 系統早期提供的事件解多工器,近年來 Linux 上會使用 epoll ,MacOS 使用 kqueue ,而 Windows 上則使用 IOCP API。

順帶一提,在 Unix 系統下檔案系統並沒有提供非阻塞的方法,因此需要在事件迴圈之外,利用多執行緒去模仿非阻塞的行為。

什麼是反應器模式?

反應器模式(The Reactor Pattern)背後最主要的概念是,每一個 I/O 操作都有一個處理器(handler)與之關聯,而處理器會在操作結束後由事件迴圈觸發。在 NodeJS 中,回調方法則扮演了處理器的角色。

反應器模式便是奠基在事件解多工器的運作模式之上,由於事件解多工器會告訴我們哪一個 I/O 資源已經就緒,故我們可以對就緒的 I/O 進行操作、依照不同的操作發布不同的事件、並執行不同的處理器傳遞 I/O 操作的結果。

反應器模式具有以下工作流程:

  1. 首先應用程式會對事件解多工器註冊一項 I/O 操作,並指定一個處理器於操作完成時呼叫。
  2. 事件解多工器會針對註冊的資源進行讀寫,一旦操作完畢,它會產生與其 I/O 操作對應的事件,並推進事件佇列(Event Queue)裡面。
  3. 事件迴圈遍歷事件佇列上的每一個事件,並觸發事件對應的處理器,處理器可註冊另一項 I/O 操作,流程回到 1.。
  4. 當事件迴圈結束遍歷後,控制權回到解多工器等待下一輪事件產生。

反應器模式的特點在於,它將應用邏輯與非阻塞 I/O 的邏輯分離,應用程式只管註冊 I/O 操作和處理器即可,提升模組化的可能性。例如在 NodeJS 中讀取檔案,我們只需呼叫 fs.readFile 並給予一個回調方法,而不必去在意檔案何時可讀、何時讀取完成,我們只需在回調方法裡面處理讀取結果(即檔案內容)即可。

import { readFile } from ’fs’readFile(’./data.txt’, (err, buf) => {
if (err) {
console.error(’something wrong’)
return
}
console.log(buf.toString())
})

而在反應器模式裡面,我們需要銘記在心的是,處理器內部耗時的同步邏輯、無限遞迴都有可能造成事件迴圈無法繼續進行,耽誤 I/O 操作甚至造成 I/O 飢餓(Starvation)。

反應器模式便是 NodeJS 背後最重要的運作原理,藉由對反應器模式更進一步的認識,我們得以因此更加了解如何與事件迴圈互動。

本來想在這一篇文章從反應器模式一路談到事件迴圈的工作流程,但發現這個主題似乎不僅僅是一篇文章就能一概而論的,因此決定就此打住,把事件迴圈的細節留給下一篇文章再來一探究竟。

從撰文的過程中,我能清楚感受到整理思路不是一件容易的事,尤其當我們吸收進來的內容是一塊又一塊零散的知識時,乍看還自以為學識淵博,實則根本沒有融會貫通,我所缺少的是一段整理知識脈絡的過程,而這影響到的則是向他人表述知識時的邏輯。

這個反省來自於最近幾次面試,我發現我的論述常常支離破碎,明明具備經驗卻無法清楚說明,因此錯失很多機會。為了彌補自己這方面的不足,因此我決定在 Medium 上重新開始這段整理思路的旅程。

--

--

Kevin Cheng
Cow Say
Editor for

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