Node.js 事件迴圈:初學者完整解析
你可能聽過 Node.js 是單執行緒,並使用事件迴圈來處理數千個並行連線。但這到底是什麼意思?如果你剛接觸 Node.js,事件迴圈可能像個神祕的黑盒子。本文將用淺顯易懂的語言拆解它,讓你寫出更好、更快的 Node.js 程式碼。
為什麼需要事件迴圈
傳統的網頁伺服器(例如搭配 PHP 的 Apache)會為每個請求建立新的執行緒或行程。這雖然可行,但當你有很多並行連線時,擴展性並不好——每個執行緒都會消耗記憶體和 CPU。Node.js 採用了不同的做法:它在單一執行緒中執行你的 JavaScript,並使用事件迴圈來非同步處理 I/O 操作。這表示你的程式碼不會等待資料庫查詢或檔案讀取完成;它會繼續執行其他任務,並在結果準備好時回來處理。
核心元件
在深入事件迴圈之前,讓我們先釐清幾個關鍵角色:
- 呼叫堆疊(Call Stack): 同步 JavaScript 程式碼執行的地方。函式在執行時會被推入和彈出。
- Node API: 處理耗時操作(如檔案 I/O、網路請求、計時器)的 C++ API。它們在背景執行(使用 libuv 執行緒池)。
- 回呼佇列(Callback Queue): 已完成的 Node API 操作的回呼等待被執行的地方。
- 事件迴圈(Event Loop): 持續檢查呼叫堆疊是否為空,若是則將回呼從佇列移到堆疊的協調者。
事件迴圈如何運作:一個簡單範例
看看這段程式碼:
console.log('Start');
setTimeout(() => {
console.log('Timeout');
}, 0);
console.log('End');
輸出是什麼?如果你猜 Start、End、Timeout,那就對了。即使逾時設定為 0 毫秒,回呼也不會立即執行。以下是實際發生的過程:
console.log('Start')被推入呼叫堆疊,執行後彈出。setTimeout被呼叫。Node.js 註冊計時器並設定在 0 毫秒後到期。回呼被儲存,函式返回。console.log('End')被推入、執行、彈出。- 現在呼叫堆疊是空的。事件迴圈檢查計時器階段,發現計時器已到期。它將回呼移到呼叫堆疊,記錄
Timeout。
這展示了非阻塞的特性:計時器不會阻塞其餘程式碼。
事件迴圈的階段
事件迴圈在幾個階段中處理回呼,每個階段都有特定目的。了解這些階段有助於你預測執行順序。
| 階段 | 說明 |
|---|---|
| 計時器(Timers) | 執行由 setTimeout() 和 setInterval() 排定的回呼。 |
| 待處理回呼(Pending Callbacks) | 執行延遲到下一個迴圈迭代的 I/O 回呼。 |
| 閒置、準備(Idle, Prepare) | Node.js 內部使用。 |
| 輪詢(Poll) | 擷取新的 I/O 事件;執行 I/O 相關回呼(幾乎所有,除了計時器、setImmediate 和關閉回呼)。 |
| 檢查(Check) | 執行由 setImmediate() 排定的回呼。 |
| 關閉回呼(Close Callbacks) | 執行關閉回呼,例如 socket.on('close', ...)。 |
在每個階段之間,Node.js 會檢查微任務(microtasks):process.nextTick() 回呼和 Promise。這些具有較高優先級,會在目前操作完成後立即執行,然後才進入下一個階段。
微任務:nextTick 與 Promise
微任務不屬於事件迴圈的階段;它們在每個階段之後和每個回呼之後被處理。這使它們在計時器和 I/O 回呼之前執行。例如:
setTimeout(() => console.log('Timeout'), 0);
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
console.log('Sync');
輸出:Sync、nextTick、Promise、Timeout。同步程式碼先執行,然後是微任務(nextTick 在 Promise 之前),最後是計時器。
警告: 遞迴呼叫 process.nextTick() 會耗盡事件迴圈,阻止 I/O 發生。遞迴操作請使用 setImmediate()。
setImmediate 與 setTimeout 的比較
setImmediate() 的設計是在目前的輪詢階段完成後立即執行回呼。相對地,0 毫秒的 setTimeout() 會等待下一個計時器階段。它們之間的順序可能因情境而異,但在 I/O 回呼內部,setImmediate() 總是先執行。
為什麼這對你的程式碼很重要
了解事件迴圈有助於你避免常見的陷阱:
- 不要阻塞事件迴圈: 同步操作如
fs.readFileSync或大量運算會阻塞整個迴圈,使你的應用程式失去回應。請使用非同步版本或卸載到工作執行緒。 - 明智地使用微任務: 它們在 I/O 之前執行,因此大量的微任務處理可能會延遲 I/O 回呼。
- 了解回呼順序: 當混合使用計時器、Promise 和 I/O 時,了解各階段以預測輸出。
實用範例:非阻塞檔案讀取
以下是事件迴圈如何實現非阻塞 I/O:
const fs = require('fs');
console.log('Before read');
fs.readFile('large-file.txt', 'utf8', (err, data) => {
if (err) throw err;
console.log('File read complete');
});
console.log('After read');
輸出:Before read、After read,然後是 File read complete。檔案讀取在背景進行,完成後回呼會被排入佇列。同時,其他程式碼可以繼續執行。
常見問題
Node.js 真的是單執行緒嗎?
Node.js 在單一執行緒中執行你的 JavaScript,但它使用 libuv 執行緒池中的多個執行緒來處理檔案 I/O、DNS 和其他操作。所以底層並非完全單執行緒。
呼叫堆疊和事件迴圈有什麼區別?
呼叫堆疊執行同步程式碼。事件迴圈管理非同步回呼,在堆疊為空時將它們從佇列移到呼叫堆疊。
我可以建立多個事件迴圈嗎?
不行,每個 Node.js 行程只有一個事件迴圈。但是,你可以使用工作執行緒來執行獨立的 JavaScript 執行緒,每個都有自己的事件迴圈。
如果你正在處理伺服器日誌以偵錯效能問題,試試我們的 Nginx 日誌分析器,快速解析並視覺化日誌資料。