什麼是事件迴圈
雖然js是單執行緒的,但是事件迴圈會盡可能地將解除安裝操作(offloading operations)託付給系統核心,讓node能夠執行非阻塞的I/O操作
由於大多數現代核心都是多執行緒的,因此它們可以處理在後臺執行的多個操作。當其中任意一個任務完成後,核心都會通知Node.js,以保證將相對應的回撥函式推入poll佇列中最終執行。稍後我們將在本文中詳細解釋這一點。
事件迴圈的定義
當Node.js服務啟動時,它就會初始化事件迴圈。每當處理到指令碼(或者是放置到REPL執行的程式碼,本文我們不提及)中非同步的API, 定時器,或者呼叫process.nextTick()
都會觸發事件迴圈,
下圖簡單描述了事件迴圈的執行順序
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
複製程式碼
注: 每個方框都是事件迴圈的一個階段
每個階段都有一個待執行回撥函式的FIFO佇列, 雖然每個階段都不盡相同,總體上說,當事件迴圈到當前階段時,它將執行特定於該階段的操作,然後就會執行被壓入當前佇列中的回撥函式, 直到佇列被清空或者達到最大的呼叫上限。 當佇列被清空或者達到最大的呼叫上限時,事件迴圈就會進入到下一階段,如此反覆。
因為任意階段的操作都有可能呼叫更多的任務和觸發新的事件,這些事件都最終會由核心推入poll階段,poll事件可以在執行事件的時候插入佇列。所以呼叫棧很深的回撥允許poll階段執行時間比定時器的閥值更久,詳細部分請檢視定時器和poll部分的內容。
注:Windows和Unix/Linux實現之間存在細微的差異,但這對於本文來說並不重要,最重要的部分在文中會一一指出。 實際上事件迴圈一共有七到八個步驟, 但是我們只需要關注Node.js中實際運用到的,也就是上文所訴的內容
階段概覽
- timers: 這個階段將會執行
setTimeout()
和setInterval()
的回撥函式 - pending callbacks: 執行延遲到下一個迴圈迭代的I/O回撥
- idle, prepare: 只會在核心中呼叫
- poll: 檢索新的I/O事件,執行I/O相關的回撥(除了結束回撥之外,幾乎所有的回撥都是由計時器和
setimmediation()
觸發的); node將會在合適的時候阻塞在這裡 - check:
setImmediate()
的回撥將會在這裡觸發 - close callbacks: 一些關閉事件的回撥, 比如
socket.on("close", ...)
在任意兩個階段之間,Node.js都會檢查是否還有在等待中的非同步I/O事件或者定時器,如果沒有就會乾淨得關掉它。
階段的細節
timers
定時器將會在一個特定的時間之後執行相應的回撥,而不是在一個通過開發者設定預期的時間執行。定時器將會在超過設定時間後儘早地執行,然而作業系統的排程或者執行的其他回撥將會將之滯後。
注: 從技術上講,poll階段會控制定時器什麼時候執行
比如說,你設定了一個100ms過後執行的定時器,但是你的指令碼在剛開始時非同步讀取檔案耗費了95ms:
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
}
});
複製程式碼
當事件迴圈進入到poll階段,它將會宣告一個空的佇列(fs.readFile()還暫時沒有完成),所以它將會等待一段時間來儘早到達定時器的閥值。當等待了95ms過後,fs.readFile()
結束讀取檔案的任務並且再花費10ms的時間去完成被推入poll佇列中的回撥,當回撥結束,此時在佇列中沒有其他回撥,這個時候事件迴圈將會看到定時器的閥值已經過了,並且是可以儘快執行的時機,這個時候回到timers階段去執行定時器的回撥。這樣來說,你將會看到定時器從開始排程到被執行間隔105ms。
注: 為了保證poll階段不出現輪訓飢餓,libuv(一個c語言庫,由他來實現Node.js的事件迴圈和所有平臺的非同步操作)會提供一個觸發最大值(取決於系統),在達到最大值過後會停止觸發更多事件。
pending callbacks
這個階段將會執行作業系統的一些回撥如同TCP的錯誤捕獲一樣。比如如果一個TCP 套接字接收到了ECONNREFUSED
在嘗試建立連結的時候,一些*nix系統就會上報當前錯誤,這個上報的回撥就會被推入pending callback的執行佇列中去。
poll
poll階段有兩個主要的功能:
- 計算什麼時候阻塞或者輪詢更多的I/O
- 執行在poll佇列中的回撥
當事件迴圈進入到poll階段並且沒有定時器在被排程中的時候,下面兩種情況中的一種會發生:
- 當poll佇列不為空,事件迴圈將會遍歷它的佇列並且同步執行他們,直到佇列被清空或者達到系統執行回撥的上限
- 如果poll佇列為空,將要發生的另外兩件事之一:
- 如果系統排程過
setImmediate()
,那麼事件迴圈將會結束poll階段然後繼續到check階段去執行setImmediate()
的回撥 - 如果系統沒有排程過
setImmediate()
, 那麼事件迴圈將等待回撥被推入佇列,然後立即執行它
- 如果系統排程過
一旦poll階段佇列為空事件迴圈將會檢查是否到達定時器的閥值,如果有定時器準備好了,那麼事件迴圈將會回到timers階段去執行定時器的回撥
check
這個階段允許開發者在poll階段執行完成後立即執行回撥函式。 如果poll階段變為空閒狀態並且還有setImmediate()
回撥,那麼事件迴圈將會直接來到check階段而不是繼續在poll階段等待
setImmediate()
實際上是執行在事件迴圈各個分離階段的特殊定時器,它直接使用libuv的API去安排回撥在poll階段完成後執行
通常上來說,在執行程式碼時,事件迴圈最終會進入輪詢階段,等待傳入連線、請求等。但是,如果還有 setImmediate()
回撥,並且輪詢階段變為空閒狀態,則它將結束並繼續到check階段而不是等待poll事件。
close callbacks
如果一個socket連線突然關閉(比如socket.destroy()),‘close’事件將會被推入這個階段的佇列中,否則它將通過process.nextTick()觸發。
setImmediate()和setTimeout()有什麼不同
setImmediate
和setTimeout
相似,但是他們在被呼叫的時機上是不同的。
setImmediate
被設計在當前poll階段完成後執行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迴圈中去,immediate
總是會先執行。
// 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()
,這樣就會阻止事件迴圈進入到poll階段
為什麼這種情況會被允許
為什麼這樣的事情會包含在 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: Maximum call stack size exceeded from 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()
,指令碼就可以按照我們預想的執行,它允許變數,函式等先在回撥執行之前被宣告。 它還有個好處是可以阻止事件迴圈進入到下一個階段,這會在進入下一個事件迴圈前丟擲錯誤時很有用。程式碼如下:
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!`);
});
複製程式碼
這裡並不能立即從建構函式中觸發event
事件。因為在此之前使用者並沒有給event
事件新增回撥。但是,在建構函式本身中可以使用 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!`);
});
複製程式碼