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