Node.js 事件迴圈
什麼是事件迴圈?
事件迴圈是 Node.js 能夠執行非阻塞 I/O 操作的關鍵——儘管 JavaScript 預設使用單個執行緒——它透過儘可能將操作解除安裝到系統核心來實現。
由於大多數現代核心都是多執行緒的,它們可以在後臺處理多個操作。當其中一個操作完成時,核心會通知 Node.js,以便將相應的回撥新增到**輪詢**佇列中,最終被執行。我們將在本主題的後面進一步詳細解釋這一點。
事件迴圈詳解
當 Node.js 啟動時,它會初始化事件迴圈,處理提供的輸入指令碼(或進入 REPL,本文件不涉及此內容),這可能會進行非同步 API 呼叫、排程定時器或呼叫 process.nextTick(),然後開始處理事件迴圈。
下圖展示了事件迴圈操作順序的簡化概述。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
每個框都將被稱為事件迴圈的一個“階段”。
每個階段都有一個先進先出(FIFO)的回撥佇列需要執行。雖然每個階段都有其特殊之處,但通常情況下,當事件迴圈進入某個階段時,它會執行該階段特有的任何操作,然後執行該階段佇列中的回撥,直到佇列耗盡或執行了最大數量的回撥。當佇列耗盡或達到回撥限制時,事件迴圈將移動到下一個階段,依此類推。
由於這些操作中的任何一個都可能排程*更多*的操作,並且在**輪詢**階段處理的新事件由核心排隊,因此在處理輪詢事件時可能會有輪詢事件入隊。因此,長時間執行的回撥可能會使輪詢階段的執行時間遠超定時器的閾值。有關更多詳細資訊,請參閱定時器和輪詢部分。
注意:Windows 和 Unix/Linux 的實現之間存在輕微差異,但這對於本次演示並不重要。最重要的部分都在這裡。實際上有七到八個步驟,但我們關心的——Node.js 實際使用的——是上面的那些。
階段概述
- timers(定時器):此階段執行由
setTimeout()和setInterval()排程的回撥。 - pending callbacks(待定回撥):執行延遲到下一個迴圈迭代的 I/O 回撥。
- idle, prepare(空閒、準備):僅供內部使用。
- poll(輪詢):檢索新的 I/O 事件;執行與 I/O 相關的回撥(幾乎所有回撥,除了關閉回撥、定時器排程的回撥和
setImmediate());在適當的時候,Node 會在此處阻塞。 - check(檢查):
setImmediate()回撥在此處被呼叫。 - close callbacks(關閉回撥):一些關閉回撥,例如
socket.on('close', ...)。
在事件迴圈的每次執行之間,Node.js 會檢查是否正在等待任何非同步 I/O 或定時器,如果沒有,則會乾淨地關閉。
從 libuv 1.45.0 (Node.js 20) 開始,事件迴圈的行為發生了變化,定時器只在**輪詢**階段之後執行,而不是像早期版本那樣在之前和之後都執行。這一變化可能會影響 setImmediate() 回撥的執行時機以及它們在某些場景下與定時器的互動方式。
階段詳解
定時器 (timers)
定時器指定了一個**閾值**,*在此之後*提供的回撥*可能會被執行*,而不是一個人*希望它被執行的***確切**時間。定時器回撥將在指定的時間過去後儘快執行;然而,作業系統排程或其他回撥的執行可能會延遲它們。
技術上,**輪詢階段**控制著定時器的執行時間。
例如,假設你排程了一個在 100 毫秒閾值後執行的超時,然後你的指令碼開始非同步讀取一個需要 95 毫秒的檔案:
const = ('node:fs');
function () {
// Assume this takes 95ms to complete
.('/path/to/file', );
}
const = .();
(() => {
const = .() - ;
.(`${}ms have passed since I was scheduled`);
}, 100);
// do someAsyncOperation which takes 95 ms to complete
(() => {
const = .();
// do something that will take 10ms...
while (.() - < 10) {
// do nothing
}
});
當事件迴圈進入**輪詢**階段時,它有一個空佇列(fs.readFile() 尚未完成),所以它會等待直到最近的定時器閾值到達所剩餘的毫秒數。在等待 95 毫秒過去後,fs.readFile() 完成檔案讀取,其需要 10 毫秒完成的回撥被新增到**輪詢**佇列並執行。當該回撥完成後,佇列中沒有更多的回撥,所以事件迴圈將看到最近定時器的閾值已經達到,然後返回到**定時器**階段以執行該定時器的回撥。在這個例子中,你會看到從排程定時器到執行其回撥的總延遲將是 105 毫秒。
為了防止**輪詢**階段餓死事件迴圈,libuv(實現 Node.js 事件迴圈和平臺所有非同步行為的 C 庫)還有一個硬性的最大值(取決於系統),在此之前它會停止輪詢更多事件。
待定回撥 (pending callbacks)
此階段為某些系統操作(例如 TCP 錯誤型別)執行回撥。例如,如果一個 TCP 套接字在嘗試連線時收到 ECONNREFUSED,一些 *nix 系統希望等待報告錯誤。這將被排隊在**待定回撥**階段執行。
輪詢 (poll)
輪詢階段有兩個主要功能:
- 1. 計算它應該阻塞和輪詢 I/O 的時間,然後
- 2. 處理**輪詢**佇列中的事件。
當事件迴圈進入**輪詢**階段*且沒有排程定時器*時,會發生以下兩種情況之一:
-
*如果**輪詢**佇列**不為空**,*事件迴圈將遍歷其回撥佇列,同步執行它們,直到佇列耗盡或達到系統依賴的硬限制。
-
*如果**輪詢**佇列**為空**,*則會發生以下兩種情況之一:
-
如果指令碼已由
setImmediate()排程,事件迴圈將結束**輪詢**階段並繼續到**檢查**階段以執行那些已排程的指令碼。 -
如果指令碼**沒有**由
setImmediate()排程,事件迴圈將等待回撥被新增到佇列中,然後立即執行它們。
-
一旦**輪詢**佇列為空,事件迴圈將檢查*時間閾值已到*的定時器。如果一個或多個定時器準備就緒,事件迴圈將返回到**定時器**階段以執行這些定時器的回撥。
檢查 (check)
此階段允許事件迴圈在**輪詢**階段完成後立即執行回撥。如果**輪詢**階段變為空閒,並且有用 setImmediate() 排隊的指令碼,事件迴圈可能會繼續到**檢查**階段,而不是等待。
setImmediate() 實際上是一個在事件迴圈的單獨階段執行的特殊定時器。它使用一個 libuv API,該 API 排程回撥在**輪詢**階段完成後執行。
通常,隨著程式碼的執行,事件迴圈最終會到達**輪詢**階段,在此它將等待傳入的連線、請求等。但是,如果一個回撥已用 setImmediate() 排程,並且**輪詢**階段變為空閒,它將結束並繼續到**檢查**階段,而不是等待**輪詢**事件。
關閉回撥 (close callbacks)
如果套接字或控制代碼被突然關閉(例如 socket.destroy()),'close' 事件將在此階段發出。否則,它將透過 process.nextTick() 發出。
setImmediate() 與 setTimeout()
setImmediate() 和 setTimeout() 很相似,但根據呼叫時機的不同,它們的行為方式也不同。
setImmediate()設計為在當前**輪詢**階段完成後執行指令碼。setTimeout()排程一個指令碼在至少經過指定的毫秒閾值後執行。
定時器執行的順序將根據它們被呼叫的上下文而變化。如果兩者都是從主模組內部呼叫的,那麼執行時機將受程序效能的約束(可能會受到機器上執行的其他應用程式的影響)。
例如,如果我們執行以下不在 I/O 週期內的指令碼(即主模組),兩個定時器的執行順序是不確定的,因為它受程序效能的約束:
// timeout_vs_immediate.js
(() => {
.('timeout');
}, 0);
(() => {
.('immediate');
});
但是,如果你將這兩個呼叫移到一個 I/O 週期內,immediate 回撥總是先執行:
// timeout_vs_immediate.js
const = ('node:fs');
.(, () => {
(() => {
.('timeout');
}, 0);
(() => {
.('immediate');
});
});
使用 setImmediate() 相對於 setTimeout() 的主要優點是,如果在 I/O 週期內排程,setImmediate() 將總是在任何定時器之前執行,無論存在多少個定時器。
process.nextTick()
理解 process.nextTick()
你可能已經注意到,process.nextTick() 沒有顯示在圖表中,儘管它是非同步 API 的一部分。這是因為 process.nextTick() 在技術上不屬於事件迴圈的一部分。相反,nextTickQueue 將在當前操作完成後被處理,無論事件迴圈的當前階段是什麼。在這裡,一個*操作*被定義為從底層的 C/C++ 處理程式轉換,並處理需要執行的 JavaScript。
回顧我們的圖表,無論你在哪個階段呼叫 process.nextTick(),所有傳遞給 process.nextTick() 的回撥都將在事件迴圈繼續之前被解析。這可能會產生一些糟糕的情況,因為它**允許你透過進行遞迴的 process.nextTick() 呼叫來“餓死”你的 I/O**,從而阻止事件迴圈到達**輪詢**階段。
為什麼會允許這樣做?
為什麼 Node.js 中會包含這樣的東西?部分原因是一種設計理念,即一個 API 應該總是非同步的,即使它不必如此。以下面的程式碼片段為例:
function (, ) {
if (typeof !== 'string') {
return .(
,
new ('argument should be string')
);
}
}
該片段進行引數檢查,如果引數不正確,它將把錯誤傳遞給回撥。該 API 最近更新,允許將引數傳遞給 process.nextTick(),使其能夠將回調之後傳遞的任何引數作為回撥的引數傳播,這樣你就不必巢狀函式。
我們所做的是將錯誤返回給使用者,但只有在*允許*使用者程式碼的其餘部分執行*之後*。透過使用 process.nextTick(),我們保證 apiCall() 總是在使用者程式碼的其餘部分*之後*和事件迴圈被允許繼續*之前*執行其回撥。為了實現這一點,JS 呼叫棧被允許展開,然後立即執行提供的回撥,這允許一個人對 process.nextTick() 進行遞迴呼叫而不會從 v8 達到 RangeError: Maximum call stack size exceeded。
這種理念可能會導致一些潛在的問題情況。以下面的程式碼片段為例:
let = null;
// this has an asynchronous signature, but calls callback synchronously
function () {
();
}
// the callback is called before `someAsyncApiCall` completes.
(() => {
// since someAsyncApiCall hasn't completed, bar hasn't been assigned any value
.('bar', ); // null
});
= 1;
使用者定義 someAsyncApiCall() 具有非同步簽名,但它實際上是同步操作的。當它被呼叫時,提供給 someAsyncApiCall() 的回撥在事件迴圈的同一階段被呼叫,因為 someAsyncApiCall() 實際上沒有做任何非同步的事情。結果,回撥試圖引用 bar,儘管它可能還沒有那個變數在作用域內,因為指令碼還沒有能夠執行到完成。
透過將回調放在一個 process.nextTick() 中,指令碼仍然能夠執行到完成,允許所有的變數、函式等在回撥被呼叫之前被初始化。它還有一個優點,就是不允許事件迴圈繼續。在事件迴圈被允許繼續之前,向用戶發出錯誤警報可能是有用的。這是使用 process.nextTick() 的前一個例子:
let = null;
function () {
.();
}
(() => {
.('bar', ); // 1
});
= 1;
這是另一個真實世界的例子:
const = net.createServer(() => {}).listen(8080);
.on('listening', () => {});
當只傳遞一個埠時,埠會立即繫結。所以,'listening' 回撥可能會立即被呼叫。問題是到那時 .on('listening') 回撥還沒有被設定。
為了解決這個問題,'listening' 事件在一個 nextTick() 中排隊,以允許指令碼執行到完成。這允許使用者設定他們想要的任何事件處理程式。
process.nextTick() 與 setImmediate()
就使用者而言,我們有兩個相似的呼叫,但它們的名字令人困惑。
process.nextTick()在同一階段立即觸發setImmediate()在事件迴圈的下一次迭代或“tick”中觸發
本質上,這兩個名字應該交換。process.nextTick() 比 setImmediate() 更快觸發,但這是歷史遺留問題,不太可能改變。進行這種轉換會破壞 npm 上很大一部分的包。每天都有更多的新模組被新增,這意味著我們等待的每一天,都會發生更多潛在的破壞。雖然它們令人困惑,但名字本身不會改變。
我們建議開發者在所有情況下都使用
setImmediate(),因為它更容易理解。
為什麼使用 process.nextTick()?
主要有兩個原因:
-
1. 允許使用者處理錯誤,清理任何不再需要的資源,或者在事件迴圈繼續之前可能再次嘗試請求。
-
2. 有時需要在呼叫棧展開後但在事件迴圈繼續前允許一個回撥執行。
一個例子是匹配使用者的期望。簡單例子:
const = net.createServer();
.on('connection', => {});
.listen(8080);
.on('listening', () => {});
假設 listen() 在事件迴圈開始時執行,但監聽回撥被放在一個 setImmediate() 中。除非傳遞了主機名,否則繫結到埠會立即發生。為了讓事件迴圈繼續,它必須到達**輪詢**階段,這意味著有非零的機會可能會接收到連線,從而在監聽事件之前觸發連線事件。
另一個例子是擴充套件一個 EventEmitter 並在建構函式內部發出一個事件:
const = ('node:events');
class extends {
constructor() {
super();
this.('event');
}
}
const = new ();
.('event', () => {
.('an event occurred!');
});
你不能立即從建構函式中發出事件,因為指令碼還沒有處理到使用者為該事件分配回撥的地步。所以,在建構函式本身內部,你可以使用 process.nextTick() 來設定一個回撥,在建構函式完成後發出事件,這提供了預期的結果:
const = ('node:events');
class extends {
constructor() {
super();
// use nextTick to emit the event once a handler is assigned
.(() => {
this.('event');
});
}
}
const = new ();
.('event', () => {
.('an event occurred!');
});