Node.js 101 — 單執行緒、非同步、非阻塞 I/O 與事件迴圈

用事件驅動、非阻塞的 Node.js 去開發快速、可擴展的 I/O 密集型的應用程式吧!

Wenchin
Wenchin Rolls Around
13 min readMar 18, 2020

--

Photo credit: Wikipedia

用了 Node.js 寫應用程式幾個月了,今天來認真理解一下什麼是事件驅動、非阻塞、非同步輸入輸出……,為什麼要有它們,還有這些東西跟 Node.js 執行環境特性的關聯。

什麼是 Node.js?

維基百科跟我們說:

Node.js 是能夠在伺服器端運行 JavaScript開放原始碼跨平台 JavaScript 執行環境。Node.js採用Google開發的V8執行程式碼,使用事件驅動非阻塞非同步輸入輸出模型等技術來提高效能,可最佳化應用程式的傳輸量和規模。這些技術通常用於資料密集的即時應用程式。

以下會由 Node.js 的特性切入,從非同步輸入 / 輸出,談到非阻塞 I/O 處理,再談事件驅動,最後總結 Node.js 適用的應用程式類型。

什麼是輸入 / 輸出 (I/O)?

I/O 就是 input / output 的簡稱,是程式跟系統記憶體或網路的互動,如讀取、寫入檔案、發送 HTTP 請求、對資料庫 CRUD 操作等等。

I/O 密集 vs. CPU 密集

一個應用程式花較多時間在 CPU 計算還是等待 I/O,決定了它屬於計算密集型 (CPU-bound),或是 I/O 密集型 (I/O-bound)。

當 CPU 快到一定的程度之後,許多的程式之所以效率不彰,主要是卡在 I/O 的處理上,所以現在很多設計關鍵就是要改善 I/O 效率、避免程序卡在 I/O 太久,這會比去升級記憶體和擴展 CPU 更經濟實惠。

以一個網頁開發者的角度來看,因為大部分的網頁應用程式都不太需要太多的 CPU 計算,反而是花時間慢慢等大量的 I/O 處理完畢(動不動就要打 HTTP 請求啊、跟資料庫要資料啊、讀取或更新檔案啊……等等),所以處理 I/O 的速度會是網頁應用程式效能的關鍵。

要怎樣才能讓等待 I/O 時不要卡住後續程式碼?

可以讓程式一邊慢慢等、一邊繼續執行其他部分程式碼(所以使用者不會因為啥都點不動、得不到網頁回覆,怒而離開網頁)的方法主要有兩種:

  1. 多執行緒 (multi-threaded):使用阻塞 (blocking) I/O 的設計
  2. 單執行緒 (single-threaded):使用非阻塞 (non-blocking) I/O 設計 + 提供非同步 (asynchronous) 處理

阻塞 I/O 是怎樣?非阻塞又是怎樣?

阻塞就是 I/O 的處理阻擋了其他後續程式碼的執行。

給個生動的例子:

阻塞後續程式碼的執行,就好像我去家裡附近的滷味攤買滷味,點好了交給老闆之後就要站在旁邊等,哪裡也不能去,因為我想吃到熱騰騰的滷味。如果我回家了然後每隔十分鐘再過來,可能滷味已經冷掉了,我不想這樣,我買的又不是冰滷味。所以我只能站在旁邊癡癡等,癡癡冷,才能在第一時間就拿到剛起鍋的滷味。

阻塞(blocking)的對照就叫做非阻塞(non-blocking),意思就是不會阻擋後續程式碼的執行,就好像我去百貨公司美食街點餐一樣,點完以後店家會給我一個呼叫器(本體是紅茶的那間速食店也有),我拿到呼叫器以後就可以回位子上等,或我想先去逛個街也可以。等到餐點準備好的時候,呼叫器就會響,我就可以去店家領取餐點,不用在原地傻傻地等。

所以都是些誰在塞?Node.js 塞不塞?

Java, Python, Ruby… 語言:多執行緒 (multi-threaded) ,本身就懂阻塞 I/O

程序會乖乖等待網路或記憶體作業完成才會繼續往下,等待期間跑這個作業的執行緒不能做其他事。如果要達到上面提到的「讓等待 I/O 時不要卡住其他程式碼」,在 Java 的解法就是開一個新的執行緒,直到任務完成,再告訴你的主執行緒「我做完囉!」

* 其實現在 Java 跟 Python 也都有了非同步的 interface,可是在使用上比 Node.js 這種本來就沒阻塞式的語言來的麻煩。

Node.js:單執行緒 (single-threaded),非阻塞 I/O + 提供非同步函式

單執行緒聽起來很潮。但如果 Node.js 是非阻塞式設計,那要怎麼等那些讀取檔案、操作資料庫、HTTP 請求的 I/O 完成呢?總不能還沒等到東西就隨便給個回覆或直接不等它,讓接下來的程式碼繼續運行吧?

所以「非同步 (asynchronous)」的出現就變得非常關鍵。

Node.js 如何實現非同步?

非同步(或是異步)函式的作用就是讓程序不用被阻擋等著 I/O 處理完,可以先跑下一行程式碼,等函式中的 callback 被呼叫的時候再執行要接著做的事。

JavaScript 實現非同步的方法不斷演進著:從 callback 函式、promises 到最新的 async-await 函式。

同步 vs. 非同步

拿 Node.js 的 File System 模組作為一個簡單的例子, 以下是同步的寫法(程序會停止前進,直到跑完 readFileSync):

const fs = require('fs');   
const data = fs.readFileSync('/file.md'); // blocks here until file is read

以下是非同步的寫法(程序會繼續往下處理後續程式碼,等readFile跑完再回來對資料doSomething):

const fs = require('fs');   
fs.readFile('/file.md', (err, data) => {
if (err) throw err;
doSomething(data);
});

為什麼 Node.js 有了非同步函式、非阻塞 I/O,就可以實現單執行緒設計?

雖然 Google V8 JavaScript 引擎是單執行緒設計,它底層的 C++ API 並不是。這代表當我們呼叫一些非同步的程式碼 (如 file system 和 child processes) 時,Node 會請它底層的 Libuv 去跑這些程式碼,跑完執行 callback 或拋出錯誤。

Photo credit: ITNEXT

Libuv 在面對非同步作業時,會開啟執行緒池 (thread pool / worker pool),預設共有四個執行緒(也就是 worker threads),運算這些 I/O 用的方式是事件迴圈 (event loop),我們接下來會討論。

所以就算 V8 處理程式碼時只有一個執行緒,一旦進到了 Libuv 手上,它還是會幫你把會阻塞的 I/O 分工到執行緒池中交給不同的執行緒處理,直到 callback 發生才丟回應用程式讓你知道。

* 你可以創造或管理自己的執行緒池,其中一個直接的方法就是透過子程序 (Child Process),但使用子程序上應該特別小心,避免幫每個 client 都建一個新的子程序,因為一旦收到客戶端請求的速度比創造、管理子程序來得快的情況發生時,很容易讓伺服器變成一個 fork bomb(程序遞迴式衍生、不斷自我複製,以使系統拒絕服務甚至崩潰)。

Libuv 如何透過事件迴圈有效處理非同步 I/O?

首先讓我們再次認識 JavaScript 裡的事件。

事件 (event)

事件指的是用戶或者系統做出的動作,例如按鈕的點擊、檔案讀取完成、某種錯誤產生……等等。

事件驅動 (event-driven)

事件驅動是一種程式執行模型,表示程式的進行是依據事件的發生而定,監聽到事件就處理、處理完就執行 callback,透過不斷的監聽跟回應事件執行程式。

而事件驅動在不同的地方有不同的實現。瀏覽器(前端)和 Node.js (後端)基於不同的技術實現了各自的事件迴圈。就 Node.js 而言,事件是交給 Libuv 去處理;至於瀏覽器的事件迴圈模型在 HTML 5 的規範中有定義。

事件迴圈 (event loop)

因為 Node.js 只有單一執行緒,所以當 Libuv 把非同步事件處理完成後,callback 要被丟回應用程式之前需要排隊,等待主執行緒的 stack 是空的時候才能開始執行。這個排隊的地方就是事件佇列 (event queue)。

Libuv 會不斷檢查有沒有 callback 需要被執行,有的話分配到主執行緒結束手邊的程序時處理,因此這整個過程稱之為「事件迴圈」。

Photo credit: Bert Belder

獨角獸就是 Libuv 的精華,也就是可能阻塞 I/O 的部分,我們下面會討論。

Bert Belder 講解 Node.js 事件迴圈的完整影片可以參考這裡:

下面這個影片雖然是以瀏覽器為討論,但如果把 web APIs 換成 C++ APIs,其實就跟在 Node.js 的事件迴圈邏輯很類似了。

Libuv 事件迴圈有哪些階段?

Photo credit: nexocode

Libuv 的事件迴圈共有六個階段,每個階段的作用如下:

  1. Timers:執行 setTimeout() 和 setInterval() 中到期的 callback,函式裡的時間值是個門檻(也就是如果設定 1000,最快可以一秒後馬上執行,但可能超過一秒才會執行),這還要看 OS 如何排序所有的 callback。
  2. Pending callbacks:上一輪迴圈中有少數的 I/O callback 會被延遲到這一輪的這一階段執行。
  3. Idle, prepare:Idle handle callback 會被執行、Prepare handles 會在迴圈被 I/O 阻塞前執行 callback。
  4. Poll:最重要的一個階段,它會尋找新的 I/O 事件,可能的話會馬上執行 I/O callback,如果無法馬上執行,它會延遲執行並把它註冊為一個 pending callback 在下一輪執行。這個階段有兩個主要的責任:計算應該阻塞多久、還有找遍所有在 polling 佇列中的事件並執行 callback,直到佇列清空或執行的 callback 數到達系統上限。這個階段會阻塞執行緒。
  5. Check:跟 prepare 相反,會在迴圈被 I/O 阻塞後執行 callback,還有執行 setImmediate() 的 callback。
  6. Close callbacks:執行 close 事件的 callback,例如 socket.destroy()。

事件迴圈就是不斷重複以上階段。每個階段都有自己的 callback 佇列,在進入某個階段時,都會從所屬的佇列中取出 callback 來執行,當佇列為空或者被執行 callback 的數量達到系統的最大數量時,就會進入下一階段。

Node.js 適合拿來寫哪種應用程式?

對單一執行緒設計的 Node.js 來說,併發性 (concurrency) 指的是事件迴圈在進行完其他任務,執行 JavaScript callback 函式的能力。

舉例來說,我們想像有個網頁每次打到伺服器的請求都需要 50 毫秒來處理,其中的 45 毫秒都是在等資料庫的 I/O。如果這時候使用 Node.js 非阻塞的非同步操作,那麼每個請求就能釋放出 45 毫秒,讓伺服器拿這些時間去處理其他請求。

因為這個特性,Node.js 很適合用來處理大量併發 (concurrent) 的運算,也就是說 Node.js 可以支援很高的吞吐量 (throughput: requests/second)。

而使用阻塞模型如 Java 等的語言會因為同步帶來佔用資源(如記憶體)的問題:因為是「來一個請求就用一個執行緒跑 (one thread per request)」的模型,所以要不斷切換執行緒,比較不利於開發大量 I/O 併發計算的應用程式。

根據以上提到事件驅動、單執行緒和非同步、非阻塞的 I/O 處理特性,Node.js 很適合拿來開發 I/O 密集型應用程式,如影音串流、即時互動、在線聊天、遊戲、協作工具、股票行情等軟體;相反的,如果需要密集的 CPU 計算,使用傳統的多執行緒設計的語言會表現較佳。

另外,如果你想只學一套語言就能同時寫好前後端,那 Node.js 也會是個不錯的選擇。

Reference

--

--