原文來自 https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/
什么是 Event Loop(事件循環(huán))?
事件循環(huán)是用來讓 Node.js 執(zhí)行非阻塞 I/O 操作的 —— 盡管 JavaScript 是單線程 —— 盡可能的向主流系統(tǒng)內(nèi)核執(zhí)行 offloading 操作。
因?yàn)榇蟛糠謨?nèi)核模型都是多線程的,他們可以在后臺執(zhí)行多個(gè)操作。當(dāng)這些操作中的某一個(gè)完成時(shí)內(nèi)核會通知 Node.js, 因此恰當(dāng)?shù)幕卣{(diào)函數(shù)可能會被加入到 輪詢 隊(duì)列來被最后執(zhí)行。我們稍后將更詳細(xì)地解釋這個(gè)主題。
事件循環(huán)的概念
當(dāng) Node.js 開始運(yùn)行時(shí),它就初始化了事件循環(huán),并且進(jìn)程提供了一種輸入腳本機(jī)制(或者順便進(jìn)入 REPL,本文沒有包括這個(gè)話題),可以被用來調(diào)用異步 API、計(jì)劃定時(shí)器、或者是調(diào)用 process.nextTick(),然后開始執(zhí)行事件循環(huán)。
下圖簡單的概述了事件循環(huán)的操作順序。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
note: 每一個(gè)盒子都被稱為事件循環(huán)的一個(gè)"階段".
每一個(gè)階段都含有一個(gè) FIFO(先進(jìn)先出)的回調(diào)隊(duì)列可以被執(zhí)行。雖然每個(gè)階段都有特殊性,但普遍地,當(dāng)事件循環(huán)進(jìn)入到一個(gè)設(shè)定好的階段時(shí),它將執(zhí)行這個(gè)特定階段的任何操作,這個(gè)階段隊(duì)列中的回調(diào)函數(shù)會一直被執(zhí)行,直到這個(gè)隊(duì)列再沒有回調(diào)函數(shù)或者已執(zhí)行的回調(diào)函數(shù)量超過最大閾值。當(dāng)這個(gè)隊(duì)列再沒有回調(diào)函數(shù)或者已執(zhí)行的回調(diào)函數(shù)量超過最大閾值時(shí),時(shí)間循環(huán)會進(jìn)到下一階段,以此類推。
因?yàn)檫@些操作中的每一個(gè)都可能在 輪詢 階段被內(nèi)核加入 更多 的操作或者新的事件進(jìn)程到隊(duì)列中,所以當(dāng)輪詢事件執(zhí)行的時(shí)候,可能有其他輪詢事件來排列等待執(zhí)行。結(jié)果是,長時(shí)間的執(zhí)行回調(diào)函數(shù)甚至可以比定時(shí)器的閾值執(zhí)行時(shí)間還要長??梢圆榭?定時(shí)器 和 輪詢 章節(jié)來了解更多細(xì)節(jié)。
_NOTE: 這個(gè)地方在 Windows 和 Unix/Linux 的實(shí)現(xiàn)上有略微差異,但是對我們的范例并不重要。重要的是,這里實(shí)際上有七到八個(gè)步驟,但是我們只需要關(guān)心 Node.js 實(shí)際使用的,那就是上面所說的那些。
階段概述
-
timers(定時(shí)器): 這個(gè)階段執(zhí)行已經(jīng)計(jì)劃好的
setTimeout()和setInterval()的回調(diào)函數(shù)。 - pending callbacks(等待回調(diào)函數(shù)階段): 被延遲到下一個(gè)循環(huán)迭代執(zhí)行的 I/O 回調(diào)函數(shù)。
- idle, perpare: 只在內(nèi)部使用。
-
poll(輪詢): 重新獲取到新的 I/O 事件;執(zhí)行 I/O 相關(guān)的回調(diào)函數(shù)(除了 close 類的 callbacks、已經(jīng)計(jì)劃好的定時(shí)器和
setImmediate()之外的幾乎所有);節(jié)點(diǎn)在恰當(dāng)?shù)臅r(shí)候會阻塞在這里。 -
check: 這個(gè)會調(diào)用
setImmediate()的回調(diào)函數(shù)。 -
close callbacks: 一些關(guān)閉類的 callbacks,比如
socket.on('close', ...)。
在每次事件循環(huán)運(yùn)行之間,Node.js 會檢查是否有正在等待執(zhí)行的任何異步 I/O 操作或者定時(shí)器,如果沒有了的話則會關(guān)閉。
階段詳情
timer(定時(shí)器)
一個(gè)定時(shí)器指定一個(gè)在其之后可能執(zhí)行所提供的回調(diào)函數(shù)的 閾值,而不是在一個(gè) 準(zhǔn)確 的時(shí)間點(diǎn)被執(zhí)行。定時(shí)器的回調(diào)函數(shù)會在指定的時(shí)間過去之后盡可能早地運(yùn)行,然后他們(定時(shí)器的回調(diào)函數(shù))可能會因?yàn)椴僮飨到y(tǒng)正在計(jì)算或者執(zhí)行其他回調(diào)函數(shù)而被延遲執(zhí)行。
_注: 從技術(shù)上講,是 輪詢 階段 控制定時(shí)器何時(shí)被執(zhí)行。
舉個(gè)例子,比如你設(shè)定了一個(gè)定時(shí)器在 100ms 的閾值后執(zhí)行,這時(shí)你的腳本開始異步讀取一個(gè)耗時(shí) 95ms 的文件:
const fs = require('fs');
function someAsyncOperation(callback) {
// 假設(shè)這個(gè)操作耗時(shí) 95ms 完成
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);
// 95ms 完成后執(zhí)行 someAsyncOperation 函數(shù)
someAsyncOperation(() => {
const startCallback = Date.now();
while (Date.now() - startCallback < 10) {
// do nothing
}
});
當(dāng)事件循環(huán)進(jìn)入 輪詢 階段時(shí),它是一個(gè)空隊(duì)列(fs.readFile() 還沒有完成),所以它會等待最早最快的定時(shí)器閾值到達(dá)。等待了 95ms 后,fs.readFile() 完成了讀文件的操作,它的回調(diào)函數(shù)加入到了 輪詢 隊(duì)列并消耗了 10ms 然后被執(zhí)行。此時(shí)回調(diào)函數(shù)完成了,輪詢中沒有其他回調(diào)函數(shù)在隊(duì)列中了,因此事件循環(huán)會查看是否有最快的定時(shí)器回調(diào)到達(dá)閾值了,然后繞回 定時(shí)器 階段去執(zhí)行定時(shí)器的回調(diào)函數(shù)。在這個(gè)例子中,你將會看到總的延遲時(shí)間是定時(shí)器的閾值和回調(diào)函數(shù)被執(zhí)行的時(shí)間相加也就是 105 ms。
注:為了防止事件循環(huán)中 輪詢 階段一直執(zhí)行(starving),libuv(實(shí)現(xiàn)了 Node.js 的事件循環(huán)機(jī)制和平臺中所有異步行為的 C 語言倉庫)會在停止輪詢所有事件的之前有一個(gè)嚴(yán)格的最大閾值(閾值大小取決于系統(tǒng))。
pending callbacks(等待回調(diào))
這個(gè)階段會執(zhí)行一些系統(tǒng)操作(如各種類型的 TCP 錯(cuò)誤)的回調(diào)函數(shù)。比如,如果在嘗試連接一個(gè) TCP 協(xié)議時(shí)接收到了 ECONNREFUSED 的報(bào)警,一些 *nix 系統(tǒng)想要等待報(bào)告這個(gè)錯(cuò)誤。它將會被排在 pending callbacks 階段執(zhí)行。
poll(輪詢)
poll 階段主要有兩個(gè)函數(shù):
- 計(jì)算 I/O 操作應(yīng)該會被阻塞和輪詢多長時(shí)間,然后
- 執(zhí)行 輪詢 隊(duì)列中的事件
當(dāng)事件循環(huán)進(jìn)入 輪詢 階段并且沒有計(jì)劃完成的定時(shí)器時(shí),下面兩個(gè)事情會執(zhí)行其中一個(gè):
- 如果 輪詢 隊(duì)列不是空的,事件循環(huán)將會同步的迭代執(zhí)行所有隊(duì)列中的回調(diào)函數(shù)直到隊(duì)列中的回調(diào)函數(shù)都執(zhí)行完成或者到達(dá)系統(tǒng)設(shè)定的執(zhí)行數(shù)最大閾值。
-
如果 輪詢 隊(duì)列空了,下面兩個(gè)事情會執(zhí)行其中一個(gè):
- 如果腳本中會計(jì)劃好的
setImmediate()函數(shù),事件循環(huán)會跳出 輪詢 階段并進(jìn)入 check(檢查) 階段執(zhí)行計(jì)劃好的腳本。 - 如果腳本中 沒有 計(jì)劃好的
setImmediate()函數(shù),事件循環(huán)將會等待回調(diào)函數(shù)被加入到隊(duì)列中,然后立即執(zhí)行它們。
- 如果腳本中會計(jì)劃好的
一旦 輪詢 階段空了,事件循環(huán)會檢查定時(shí)器的時(shí)間閾值是否到了。如果定時(shí)器的時(shí)間閾值到了,事件循環(huán)會繞回到 定時(shí)器 階段執(zhí)行這些定時(shí)器的回調(diào)函數(shù)。
check(檢查)
這個(gè)階段會在 輪詢 階段完成后立即執(zhí)行回調(diào)函數(shù)。如果 輪詢 階段閑置了并且 setImmediate 的回調(diào)函數(shù)已經(jīng)被排到隊(duì)列中了,事件循環(huán)已經(jīng)不會等待直接進(jìn)入 check 階段。
setImmediate 實(shí)際上是一個(gè)特別的定時(shí)器,它被事件循環(huán)執(zhí)行在一個(gè)單獨(dú)的階段。它使用了一個(gè) libuv API,這個(gè) API 設(shè)定了一個(gè)回調(diào)函數(shù)會在 輪詢 階段完成后執(zhí)行。
通常,當(dāng)代碼被執(zhí)行時(shí),事件循環(huán)會最后執(zhí)行 輪詢 階段(等待一個(gè)連接,請求,等等)。然而,如果有一個(gè) setImmediate() 的回調(diào)函數(shù)被計(jì)劃好了,并且 輪詢 階段是閑置的,那它將會結(jié)束并進(jìn)入到 檢查 階段而不是一直等待 輪詢 事件。
close callbacks(結(jié)束類的回調(diào)函數(shù))
如果一個(gè) socket 或者 handle 突然被關(guān)閉了(比如 socket.destory()),那個(gè) 'close' 事件會在這個(gè)階段被觸發(fā)。另外它將會通過 process.nextTick() 被觸發(fā)。
setImmediate() vs setTimeout()
setImmediate 和 setTimeout 是類似的,但是根據(jù)它們被調(diào)用的時(shí)機(jī)表現(xiàn)出不同的方式。
setImmediate()被設(shè)計(jì)為一旦當(dāng)前的 輪詢 階段完成就會被執(zhí)行的腳本。setTimeout()被設(shè)計(jì)為經(jīng)過一個(gè)經(jīng)過多少 ms 最小時(shí)間閾值之后會執(zhí)行的腳本。
計(jì)時(shí)器執(zhí)行的順序會根據(jù)調(diào)用它們的上下文而變化。如果兩者都是從主模塊中被調(diào)用的,那么計(jì)時(shí)將受到進(jìn)程性能的制約(機(jī)器上運(yùn)行的其他應(yīng)用程序的影響)。
舉個(gè)例子,如果我們執(zhí)行下面的代碼(不在 I/O 輪詢中,即主模塊),那么這兩個(gè)定時(shí)器執(zhí)行的順序是不確定的,會受到進(jìn)程性能的制約。
// 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
然而,如果你在一個(gè) I/O 周期中執(zhí)行兩者,則 setImmediate 的回調(diào)總會在 setTimeout 之前執(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
與使用 setTimeout 相比 setImmediate 主要的優(yōu)勢是,如果在一個(gè) I/O 周期調(diào)用 setImmediate(),那么它總是比任何一個(gè)定時(shí)器都先執(zhí)行,并不受存在多少個(gè)定時(shí)器的影響。
process.nextTick()
了解 process.nextTick()
你可能注意到了,process.nextTick() 并沒有在圖例中出現(xiàn),雖然它是異步 API 的一部分。這是因?yàn)?process.nextTick() 在技術(shù)上并不是事件循環(huán)的一部分。反而,nextTickQueue 會在當(dāng)前操作完成之后執(zhí)行,無論事件循環(huán)正處于什么階段?;仡^再看一下我們的圖例,在事件循環(huán)給定的任意一個(gè)階段中使用 process.nextTick(),所有通過 process.nextTick() 的回調(diào)函數(shù)都會在事件循環(huán)要繼續(xù)之前被執(zhí)行。這也會創(chuàng)造出一些不好的情況,因?yàn)?它允許你通過制造遞歸的 process.nextTick 方法來使你的 I/O 一直處于"饑餓"狀態(tài),即阻止事件循環(huán)到達(dá) 輪詢 階段。
為什么這種情況被允許
為什么 Node.js 會包含這種情況?它的這一部分是一種設(shè)計(jì)理念,即 API 應(yīng)該一直是異步的,盡管它不應(yīng)該是。那下面的代碼舉個(gè)例子:
function apiCall(arg, callback) {
if (typeof arg !== 'string')
return process.nextTick(callback, new TypeError('argument shoud be string'));
}
這段代碼執(zhí)行了一個(gè)參數(shù)檢查,并且如果類型不正確會把錯(cuò)誤傳遞給回調(diào)函數(shù)中。API 最近進(jìn)行了更新,允許給 process.nextTick() 傳遞參數(shù),通過在回調(diào)函數(shù)后面?zhèn)鬟f其他任何參數(shù)到回調(diào)函數(shù)中,這樣就不需要嵌套函數(shù)了。
我們要做的是將一個(gè)錯(cuò)誤返回給用戶,但是這僅僅是在我們已經(jīng)允許用戶的其余代碼執(zhí)行 之后 了。通過使用 process.nextTick() 我們確保 apiCall() 總是在用戶的其余代碼 之后 執(zhí)行它的回調(diào)函數(shù),并且在事件循環(huán)被允許進(jìn)行的 之前。要做到這一點(diǎn),JS 調(diào)用棧就會允許立即執(zhí)行函數(shù)提供一個(gè)回調(diào)函數(shù),這個(gè)回調(diào)函數(shù)允許遞歸的使用 process.nextTick() 而不是拋出 RangeError: Maximum call stack size exceeded from v8。
這種設(shè)計(jì)會導(dǎo)致一些潛在的問題狀況。拿下面的代碼舉例:
let bar;
// 這是一個(gè)異步的函數(shù),但是執(zhí)行一個(gè)同步的回調(diào)函數(shù)
function someAsyncApiCall(callback) { callback(); }
// 這個(gè)回調(diào)在 `someAsyncApiCall` 完成前被執(zhí)行了
someAsyncApiCall(() => {
// 在 someAsyncApiCall 完成時(shí), bar 沒有拿到任何值
console.log('bar', bar); // undefined
});
bar = 1;
用戶定義了 someAsyncApiCall() 有一個(gè)異步的標(biāo)記,但是實(shí)際上操作是同步的。當(dāng)它運(yùn)行時(shí),回調(diào)函數(shù)就提供給了 someAsyncApiCall() 來執(zhí)行在相同的事件循環(huán)階段因?yàn)?someAsyncApiCall() 實(shí)際上沒有任何的異步操作。因此,回調(diào)函數(shù)嘗試引用 bar ,雖然在作用域中可能還有這個(gè)變量,因?yàn)槟_本還沒有運(yùn)行完成。
通過在回調(diào)函數(shù)替換到 process.nextTick() 中執(zhí)行,腳本仍然可以完成運(yùn)行,允許所有的定義變量,函數(shù),等等,初始化變量會優(yōu)先回調(diào)函數(shù)執(zhí)行。它還有一個(gè)優(yōu)勢是可以不允許事件循環(huán)繼續(xù)執(zhí)行。這一點(diǎn)可能會幫助用戶在允許事件循環(huán)繼續(xù)執(zhí)行前拋出一個(gè)錯(cuò)誤。下面是使用 process.nextTick() 實(shí)現(xiàn)剛才的示例:
let bar;
function someAsyncApiCall(callback) {
process.nextTick(callback);
}
someAsyncApiCall(() => {
console.log('bar', bar); // 1
});
bar = 1;
下面是另一個(gè)真實(shí)的示例:
const server = net.createServer(() => {}).listen(8080);
server.on('listening', () => {});
當(dāng)只有一個(gè)端口號被設(shè)定時(shí),這個(gè)端口號會立即被綁定。因此,'listening' 的回調(diào)函數(shù)會立即被調(diào)用。但是問題是 .on('listening') 在那時(shí)候還沒有被設(shè)定。
為了解決這個(gè)問題, 'listening' 事件要在一個(gè) nextTick() 中排隊(duì)來允許腳本完成后再執(zhí)行。這允許用戶設(shè)置任何他們想要的事件處理程序。
process.nextTick() vs setImmediate
就用戶而言,我們有兩個(gè)類似的調(diào)用,但是它們的命名令人困惑。
process.nextTick()在同一階段立即被調(diào)用setImmediate在事件循環(huán)的迭代序列中或者在 'tick' 中被調(diào)用
本質(zhì)上,它們的命名應(yīng)該相互交換。process.nextTick() 會比 setImmediate 先執(zhí)行,但是這是歷史中無法改變的事實(shí)。改變它將會破壞 npm 中相當(dāng)大比例的包。每天都會加入大量新的模塊,意味著我們要等待每天大量潛在的破壞會發(fā)生。所以盡管這兩者比較混亂但是它們的命名也不會被改變。
我們推薦開發(fā)者在所有的情況下使用 setImmediate() 因?yàn)樗菀淄评?(并且它在更寬泛的環(huán)境中兼容性更高,比如瀏覽器腳本。)
為什么使用 process.nextTick()?
主要有兩點(diǎn)原因:
允許使用者拋出錯(cuò)誤,清理不需要的資源,或者嘗試在事件循環(huán)繼續(xù)之前重新發(fā)送請求。
有時(shí),確實(shí)需要在調(diào)用棧解除后但在事件循環(huán)繼續(xù)之前執(zhí)行回調(diào)。
這個(gè)例子很符合用戶的期望。比如:
const net = require('net');
const server = net.createServer();
server.on('connection', (conn) => {});
server.listen(8080);
server.on('listening', () => { });
listen() 是運(yùn)行在事件循環(huán)的一開始的,但是 listening 的回調(diào)函數(shù)被放在 setImmediate 中。除非一個(gè)端口號被傳入,綁定的端口號會立即生效。為了使事件循環(huán)繼續(xù),它需要到達(dá) 輪詢 階段,這意味著有這樣的可能性:連接可能已經(jīng)接收到在監(jiān)聽事件之前的連接事件調(diào)用方法。
另一個(gè)例子是運(yùn)行一個(gè)構(gòu)造函數(shù),它繼承自 EventEmitter 并且它在構(gòu)造函數(shù)中調(diào)用一個(gè)事件:
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ā)一個(gè)事件,因?yàn)槟_本還沒有處理到用戶為這個(gè)事件設(shè)置一個(gè)回調(diào)函數(shù)的位置。因此,在這個(gè)構(gòu)造函數(shù)內(nèi)部,你可以使用 process.nextTick() 來設(shè)置一個(gè)回調(diào)函數(shù)在構(gòu)造函數(shù)完成后執(zhí)行這個(gè)事件,這樣就可以得到我們期望的結(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!');
});