11原文:The Node.js Event Loop, Timers, and process.nextTick()
什么是事件循環(huán)?
事件循環(huán)通過將操作分給系統(tǒng)內(nèi)核來處理使得使用單線程的 JavaScript 的 Node.js 可以進行無阻塞 I/O 操作。
由于大部分現(xiàn)代內(nèi)核都是多線程的,所以可以在后臺同時處理多個操作。當(dāng)有操作完成時,內(nèi)核會告訴 Node.js,Node.js 將合適的回調(diào)加入輪詢隊列等待被執(zhí)行。
事件循環(huán)解析
在 Node.js 啟動的時候,一步步地做了:初始化事件循環(huán),處理可能包含異步 API 調(diào)用的輸入腳本(用戶代碼)(或進入 REPL,這里不講 REPL),調(diào)度定時器,或者調(diào)用 process.nextTick()
。然后開始處理事件循環(huán)。
下圖顯示了事件循環(huán)的操作順序的簡化概覽。
注:每一格稱為事件循環(huán)的一個階段。
每一階段都有一個先進先出的待執(zhí)行任務(wù)隊列。而在每一階段內(nèi)部有自己的執(zhí)行方法,也就是說,當(dāng)進入其中一個階段時,會執(zhí)行任何該階段自己特定的操作,然后才執(zhí)行在該階段的隊列中的回調(diào),直到隊列里的回調(diào)都執(zhí)行完了或執(zhí)行的次數(shù)達(dá)到最大限制。當(dāng)隊列耗盡或執(zhí)行的次數(shù)達(dá)到最大限制時,事件循環(huán)進入下一個階段,如此循環(huán)。
由于這些操作可以安排更多別的操作,并且在輪詢階段處理的新事件都是由內(nèi)核入隊的,則輪詢事件可以在處理輪詢事件時入隊。從而長時間運行的回調(diào)可以讓輪詢階段運行時間長于定時器的閾值。詳見后文。
注: Windows 和 Unix/Linux 之間對這些的實現(xiàn)存在細(xì)微差別,但對于此文而言并不重要。實際上有七到八個步驟,但是我們關(guān)心的、Node.js 真正用到的這里都講到了。
事件循環(huán)階段一覽
定時器:這一階段執(zhí)行由 setTimeout()
和 setInterval()
設(shè)置的回調(diào)。
I/O 回調(diào):執(zhí)行除關(guān)閉回調(diào)、定時器調(diào)度的回調(diào)和 setImmediat()
以外的幾乎所有的回調(diào)。
ide,prepare:僅內(nèi)部使用。
輪詢:獲取新的 I/O 事件;適當(dāng)?shù)臅r候這里會被阻斷。
check: setImmediate()
的回調(diào)。
關(guān)閉事件回調(diào):如 socket.on('close', ...)
的回調(diào)。
在事件循環(huán)的每次運行之間, Node.js 會檢查是否在等待任何異步 I/O 或定時器,如果兩個都沒有就自動關(guān)閉。
事件循環(huán)階段詳解
定時器
定時器在給出的回調(diào)后面指定了等待多長時間后執(zhí)行這個回調(diào),而事實上實際執(zhí)行這個任務(wù)的等待時間往往大于指定的等待時間。定時器給出的回調(diào)任務(wù)在達(dá)到等待時間后會盡可能快地被執(zhí)行;然而,操作系統(tǒng)調(diào)度或運行其他回調(diào)任務(wù)會使應(yīng)被執(zhí)行的任務(wù)被延遲執(zhí)行。
注:技術(shù)上來說,輪詢階段控制定時器什么時候可以執(zhí)行回調(diào)。
例如,先指定了一個閾值為 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
}
});
當(dāng)事件循環(huán)進入輪詢階段,任務(wù)隊列還是空的( fs.readFile()
還沒執(zhí)行完),所以這里開始等待 fs.readFile()
執(zhí)行完或有一個定時器達(dá)到閾值。這里 95ms 更快到達(dá),即文件先讀完然后回調(diào)添加到輪詢隊列并開始執(zhí)行,而該回調(diào)任務(wù)需要花費 10ms 來執(zhí)行。在執(zhí)行完這個任務(wù)以后進入定時器階段時發(fā)現(xiàn)有定時器閾值到了,可以開始執(zhí)行了,然后開始執(zhí)行這個定時器回調(diào)。在這個例子里,實際等待時間比指定的等待時間多了 5ms。
注:為了防止輪詢階段獨占事件循環(huán)而使得其它階段一直無法被執(zhí)行, libuv (一個
實現(xiàn)了 Node.js 事件循環(huán)機制和所有異步行為的 C 庫)在停止對更多事件的輪詢之前也有一個依賴于系統(tǒng)的最大值。
I/O 回調(diào)
這一階段執(zhí)行一些如 TCP 錯誤類型這類的系統(tǒng)操作回調(diào)。舉例來說,如果一個正在嘗試連接的 TCP 收到了 ECONNREFUSED
,一些系統(tǒng)要報告這個錯誤,此時要等待時機,這時這個錯誤報告就被排入 I/O 回調(diào)的隊列里。
輪詢
這個階段有兩個主要的功能:
1、為閾值已經(jīng)到了的定時器執(zhí)行一些腳本后進入2。
2、處理隊列里的事件。
當(dāng)事件循環(huán)進入這個階段且沒有定時器時,則:
- 如果輪詢回調(diào)隊列里不為空,事件循環(huán)將遍歷回調(diào)隊列,同步執(zhí)行隊列里的任務(wù)直到隊列空了或達(dá)到依賴于系統(tǒng)的最大值。
- 如果隊列為空,則:
- 如果存在
setImmediate()
,事件循環(huán)將結(jié)束這個階段進入 check 階段來執(zhí)行setImmediate()
的回調(diào)。 - 如果不存在
setImmediate()
,事件循環(huán)將等待輪詢階段的回調(diào)入隊,然后立刻執(zhí)行這些回調(diào)。
- 如果存在
一旦輪詢隊列為空,事件循環(huán)將檢查是否有閾值到達(dá)了的定時器,如果有,事件循環(huán)將返回到定時器階段來執(zhí)行這些定時器的回調(diào)。
check
這個階段允許我們在輪詢階段完成后立刻執(zhí)行一些回調(diào)。如果輪詢階段變?yōu)榭臻e,并且有 setImmediate()
的回調(diào)排隊,那么事件循環(huán)可能會繼續(xù)進入 check 階段,而不是等待輪詢回調(diào)入隊。
setImmediate()
實際上是一個特殊的定時器,它在事件循環(huán)的一個單獨的階段中運行。在輪詢階段完成之后,它使用一個 libuv API 調(diào)度回調(diào)執(zhí)行。
一般來說,隨著代碼執(zhí)行,事件循環(huán)最終會到達(dá) check 階段,在該階段等待一個傳入連接、請求等。然而如果有一個回調(diào)里調(diào)用了 setImmediate()
且輪詢階段空閑,此時將進入 check 階段而不是等待輪詢階段的回調(diào)任務(wù)。
關(guān)閉事件回調(diào)
如果一個 socket 或 handle 突然關(guān)閉(如:socket.destroy()
),這個階段將發(fā)送 close
事件。否則這個 close
事件將通過 process.nextTick()
發(fā)送。
setImmediate()
VS setTimeout()
setImmediate()
和 setTimeout()
很像,區(qū)別在于執(zhí)行的時間點:
-
setImmediate()
在當(dāng)前輪詢階段完成后執(zhí)行。 -
setTimeout()
在達(dá)到所定的時間(單位:ms)以后被執(zhí)行。
它們被執(zhí)行的順序依賴于它們在上下文中的位置。如果這兩個都是在主模塊內(nèi)部調(diào)用的,那么定時器將受到進程性能的限制(受運行在這個機器上的其它應(yīng)用程序影響)。
例如,如果我們在一個 I/O 循環(huán)之外(即主模塊)運行以下代碼,這兩個定時器被執(zhí)行的順序是不確定的,這要看進程的性能:
// 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
然而,如果將這兩個的調(diào)用放在一個 I/O 循環(huán)里, setImmediate()
將先被執(zhí)行:
// 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()
的主要優(yōu)點是:如果是在一個 I/O 循環(huán)里調(diào)用, setImmediate()
將總是在任何一個定時器之前被執(zhí)行。
process.nextTick()
理解 process.nextTick()
也許你注意到了盡管 process.nextTick()
是異步 API 的一部分,但是它不在之前的循環(huán)圖里。這是因為在技術(shù)上 process.nextTick()
并不是事件循環(huán)里的一部分。不管事件循環(huán)的當(dāng)前階段是什么, nextTickQueue
都將在當(dāng)前操作完成后被執(zhí)行。
回顧我們的循環(huán)圖,在任一給定階段調(diào)用 process.nextTick()
,所有由 process.nextTick()
調(diào)度的回調(diào)將在事件循環(huán)繼續(xù)之前得到解決。這會造成一些不好的情況,因為通過遞歸調(diào)用 process.nextTick()
可以讓 I/O 一直處于等待狀態(tài),這同時也讓事件循環(huán)到不了輪詢階段。
為何 process.nextTick()
還存在
為什么像這樣的一個方法還存在于 Node.js 中呢?一部分是因為這是一種設(shè)計理念,即 API 即使在不需要的地方也應(yīng)該始終是異步的。見下面這段代碼:
function apiCall(arg, callback) {
if (typeof arg !== 'string')
return process.nextTick(callback,
new TypeError('argument should be string'));
}
這里進行了一個參數(shù)檢查,如果參數(shù)不正確就返回一個錯誤給回調(diào)。這個 API 最近更新了,變成允許傳遞參數(shù)給 process.nextTick()
,這使得在將傳入的回調(diào)當(dāng)做參數(shù)傳給 process.nextTick()
后還可以傳任何別的參數(shù),這樣就不用嵌套函數(shù)了。
我們要做的是在執(zhí)行了調(diào)用者其余的代碼(在
apiCall 以外的)以后返回一個錯誤給調(diào)用者。通過使用 process.nextTick()
保證了 apiCall()
的回調(diào)永遠(yuǎn)能在執(zhí)行完調(diào)用者其它的代碼以后且在事件循環(huán)繼續(xù)之前被執(zhí)行。為了實現(xiàn)這一點, JS 調(diào)用棧可以被釋放,然后立刻執(zhí)行給出的回調(diào),這個回調(diào)可以遞歸調(diào)用 process.nextTick()
而不會得到 RangeError: Maximum call stack size exceeded from v8
錯誤。
這個設(shè)計理念可以導(dǎo)致一些潛在的問題。看以下一段代碼:
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()
應(yīng)該有一個異步行為,但是事實上是同步操作的。當(dāng)它被調(diào)用的時候,傳入的回調(diào)在事件循環(huán)的同一階段里被調(diào)用,因為 someAsyncApiCall()
并沒有做任何異步的事情。因此,當(dāng)傳入的回調(diào)要引用 bar
的時候 bar
被賦值的那一步還沒被執(zhí)行到,此時 bar
還是 undefined
。
通過在回調(diào)里用 process.nextTick()
來替代就能讓代碼運行到最后然后才去執(zhí)行回調(diào)。還有一個優(yōu)點是讓事件循環(huán)不能繼續(xù)。這可以用于在事件循環(huán)繼續(xù)之前給出一個錯誤提示。以下代碼是使用了 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
的回調(diào)可以被立刻執(zhí)行,問題是 .on('listening')
在這個時間點可能還沒設(shè)置好。
要正確獲取到這個 listening
事件的話要使用一個 nextTick()
放在 listen
外層,讓主模塊代碼先運行完再執(zhí)行這個 listen
。
process.nextTick() vs setImmediate()
這兩個方法的名字容易引起誤解。
-
process.nextTick()
在同一階段立刻執(zhí)行 -
setImmediate()
在事件循環(huán)的下一迭代或tick
里執(zhí)行
從本質(zhì)上來看它們的名字應(yīng)該交換下比較好。process.nextTick()
比setImmediate()
更 ‘immediate’,但這是過去定好的現(xiàn)在不可能再改了。如果真要交換的話可能破壞一大部分的 npm 包。每天都有很多模塊加入 npm 里,這意味著我們每多等一天就有更多可能被影響的包出現(xiàn)。因此它們的名字不會改變。
我們建議開發(fā)者在所有情況下都使用 setImmediate()
而不是 process.nextTick()
因為 setImmediate()
更容易被理解(且?guī)砀鼜V泛的兼容性,如瀏覽器 JS )。
為什么使用 process.nextTick()
有兩個原因:
1、讓用戶處理錯誤,清理干凈不需要的資源,或可能在事件循環(huán)繼續(xù)之前重試一下。
2、有時需要在調(diào)用棧被釋放之后且在事件循環(huán)繼續(xù)之前運行一些回調(diào)。
舉個簡單的例子:
const server = net.createServer();
server.on('connection', (conn) => { });
server.listen(8080);
server.on('listening', () => { });
這里 listen()
在事件循環(huán)的一開始就執(zhí)行了,但是監(jiān)聽 listening
事件的回調(diào)在一個 setImmediate()
里面。除非將主機名傳遞給這個端口,否則這些將立即發(fā)生。此時事件循環(huán)要繼續(xù)下去的話必須到達(dá)輪詢階段,這意味著需有一個連接在 listening
事件之前觸發(fā)。
另一個例子是運行一個繼承了 EventEmitter
的構(gòu)造函數(shù),且想要在構(gòu)造函數(shù)中調(diào)用一個事件:
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!');
});
這里不能立刻從構(gòu)造函數(shù)中發(fā)出一個事件因為該腳本還沒處理到用戶為該事件指定回調(diào)的點。在構(gòu)造函數(shù)里面可以使用 process.nextTick()
來設(shè)置一個回調(diào)來在構(gòu)造函數(shù)完成后發(fā)出這個事件,這能得到預(yù)期的結(jié)果:
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!');
});