什么是事件循環(huán)?
事件循環(huán)允許Node.js執(zhí)行非阻塞I/O操作 - 盡管JavaScript是單線程的 - 只要可能就將操作卸載到系統(tǒng)內(nèi)核。
由于大多數(shù)現(xiàn)代內(nèi)核都是多線程的,他們可以在后臺處理多個正在執(zhí)行的操作。 當(dāng)其中一個操作完成時,內(nèi)核會通知Node.js,以便可以將相應(yīng)的回調(diào)添加到輪詢隊列中得到最終執(zhí)行。 我們將在本主題后面進一步詳細(xì)解釋這一點。
事件循環(huán)解釋
當(dāng)Node.js啟動時,它就會初始事件循環(huán),處理提供的輸入腳本(或者進入REPL:Read-Eval-Print-Loop,本文不會提及。),這可能會導(dǎo)致異步API調(diào)用,調(diào)度定時器或調(diào)用process.nextTick(),然后開始處理事件循環(huán)。
下面的圖表展示的是一個事件循環(huán)的操作順序的簡單概述:
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
注意:每個方框?qū)⒈环Q為事件循環(huán)的一個“階段”。
每個階段有一個要執(zhí)行的先進先出的回調(diào)隊列。雖然每個階段都有其特定的方式,但通常情況下,當(dāng)事件循環(huán)進入給定階段時,它將執(zhí)行特定于該階段的任何操作,然后在該階段的隊列中執(zhí)行回調(diào),直到隊列耗盡或已執(zhí)行回調(diào)的最大數(shù)量。當(dāng)隊列耗盡或達到回調(diào)限制時,事件循環(huán)將移至下一個階段,依此類推。
由于這些操作中的任何一個都可以調(diào)度更多操作,并且在輪詢階段處理的新事件由內(nèi)核排隊,所以輪詢事件可以在輪詢事件正在處理時排隊。 因此,長時間運行的回調(diào)可以使輪詢階段的運行時間遠遠超過計時器的閾值。 有關(guān)更多詳細(xì)信息,請參閱定時器和輪詢部分。
注意:Windows和Unix/Linux實現(xiàn)之間略有差異,但對這里的表述不重要。 最重要的部分就在這里。 實際上有七八個步驟,但我們關(guān)心的那些 - Node.js實際使用的那些 - 就是上述這些。
階段概述
- timers: 這個階段執(zhí)行由
setTimeout()和setInterval()注冊的回調(diào)。 - I/O callbacks: 執(zhí)行幾乎所有的回調(diào),除了關(guān)閉回調(diào),由定時器注冊的回調(diào),和
setImmediate() - idle, prepare: 只是內(nèi)部使用
- poll: 檢索新的I/O事件;在占用時節(jié)點將會阻塞在這里。
- check:
setImmediate()回調(diào)會在這里被調(diào)用。 - close callbacks: 比如
socket.on('close', ...)。
在事件循環(huán)的每次運行之間,Node.js會檢查它是否正在等待任何異步I/O或定時器,并在沒有時清除關(guān)閉。
階段詳解
計時器
計時器規(guī)定了一個閾值,這個閾值是注冊的回調(diào)才可能被執(zhí)行的時間,而不是人們希望執(zhí)行的確切時間。 定時器回調(diào)會盡可能早的在指定的時間過后執(zhí)行; 但是,操作系統(tǒng)調(diào)度或其他回調(diào)的運行可能會延遲它們。
注意:技術(shù)上來講,當(dāng)計時器被執(zhí)行時,poll階段會進行控制。
比如:假設(shè)你注冊了一個延時是在100ms之后調(diào)用,然后,你的腳本開始是異步讀取一個文件花了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)進入輪詢階段時,它有一個空隊列(fs.readFile()尚未完成),因此它將等待剩余的毫秒數(shù),直到計時器的閾值最早達到。 當(dāng)?shù)却?5ms后,fs.readFile()完成讀取文件,并且需要10ms來完成添加回調(diào)到輪詢隊列并執(zhí)行的操作。當(dāng)回調(diào)完成時,隊列中沒有更多的回調(diào)了,所以事件循環(huán)會看到已經(jīng)達到最快計時器的閾值,然后回到計時器階段以執(zhí)行計時器的回調(diào)。在這個例子中,你會看到被調(diào)度的定時器和它正在執(zhí)行的回調(diào)之間的總延遲將是105ms。
注意:為防止輪詢階段時事件循環(huán)挨餓空閑,在停止輪詢之前,為了執(zhí)行更多的事件,libuv(實現(xiàn)Node.js事件循環(huán)和平臺所有異步行為的C庫)也有一個硬性最大值(取決于系統(tǒng))。
I/O callbacks
此階段是執(zhí)行某些系統(tǒng)操作(如TCP錯誤類型)注冊的回調(diào)。例如,如果嘗試連接時TCP套接字收到ECONNREFUSED,則某些*nix系統(tǒng)要等待報告該錯誤。這將排隊在I/O回調(diào)階段執(zhí)行。
poll
在輪詢階段有兩個主要功能:
對閾值已到的定時器執(zhí)行腳本,然后處理輪詢隊列中的事件。
當(dāng)事件循環(huán)進入輪詢階段并且沒有計時器時,會發(fā)生以下兩件事之一:
如果輪詢隊列不為空,則事件循環(huán)將遍歷其回調(diào)隊列,同步執(zhí)行它們,直到隊列耗盡或達到系統(tǒng)相關(guān)的強制限值。
-
如果輪詢隊列為空,則還發(fā)生以下兩件事之一:
- 如果腳本已通過
setImmediate()進行了調(diào)度,則事件循環(huán)將結(jié)束輪詢階段并繼續(xù)執(zhí)行檢查階段以執(zhí)行這些被調(diào)度的腳本。 - 如果腳本沒有通過
setImmediate()進行調(diào)度,則事件循環(huán)將等待將回調(diào)添加到隊列中,然后立即執(zhí)行它們。
- 如果腳本已通過
一旦輪詢隊列為空,事件循環(huán)將檢查已達到時間閾值的定時器。 如果一個或多個定時器準(zhǔn)備就緒,則事件循環(huán)將回退到定時器階段以執(zhí)行這些定時器的回調(diào)。
check
此階段允許在輪詢階段結(jié)束后立即執(zhí)行回調(diào)。 如果輪詢階段變得空閑并且腳本已經(jīng)通過setImmediate()排隊,則事件循環(huán)可能會繼續(xù)檢查階段而不是等待。
setImmediate()實際上是一個特殊的定時器,它在事件循環(huán)的一個單獨的階段中運行。它使用libuv API來調(diào)度回調(diào),以在輪詢階段完成后執(zhí)行。
通常,隨著代碼的執(zhí)行,事件循環(huán)將最終進入輪詢階段,在那里它將等待傳入的連接,請求等。但是,如果使用setImmediate()注冊了回調(diào)并且輪詢階段變?yōu)榭臻e,事件循環(huán)將繼續(xù)進行檢查階段,而不是等待輪詢事件。
close callbacks
如果套接字或句柄突然關(guān)閉(例如socket.destroy()),則在此階段將觸發(fā)'close'事件。 否則它將通過process.nextTick()觸發(fā)。
setImmediate() vs setTimeout()
setImmediate and setTimeout() 很相似,但其行為方式是不一樣的,這取決于它們何時被調(diào)用。
setImmediate()用于在當(dāng)前輪詢階段完成后執(zhí)行腳本。
setTimeout()在經(jīng)過最小閾值(以毫秒為單位)后調(diào)度腳本運行。
定時器執(zhí)行的順序取決于它們被調(diào)用的上下文。 如果兩者都是在主模塊內(nèi)調(diào)用的,那么時序?qū)⑹艿竭M程性能的限制(可能會受到計算機上運行的其他應(yīng)用程序的影響)。
例如,如果我們運行以下不在I/O周期內(nèi)的腳本(即主模塊),則兩個定時器的執(zhí)行順序是非確定性的,因為它受過程執(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
但是,如果在I/O周期內(nèi)移動這兩個調(diào)用,則立即回調(diào)總是首先執(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周期內(nèi)進行調(diào)度,setImmediate()將始終在任何計時器之前被執(zhí)行,不管當(dāng)前有多少個計時器。
process.nextTick()
理解 process.nextTick()
您可能已經(jīng)注意到process.nextTick()沒有顯示在圖中,即使它是異步API的一部分。 這是因為process.nextTick()在技術(shù)上并不是事件循環(huán)的一部分。 相反,nextTickQueue將在當(dāng)前操作完成后處理,而不管事件循環(huán)處于當(dāng)前哪個階段。
回顧那張圖,你在給定的階段任何時候調(diào)用process.nextTick(),所有傳遞給process.nextTick()的回調(diào)都將在事件循環(huán)繼續(xù)之前被解決。 這可能會造成一些不好的情況,因為它允許你通過遞歸調(diào)用process.nextTick()來“餓死”你的I/O,從而阻止事件循環(huán)到達輪詢階段。
為什么可以這樣?
為什么像這樣的東西被包含在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ù)檢查,如果不正確,它會將錯誤傳遞給回調(diào)函數(shù)。最近更新的API允許將參數(shù)傳遞給process.nextTick(),允許它將回調(diào)后傳遞的任何參數(shù)作為參數(shù)傳播給回調(diào)函數(shù),這樣就不必嵌套函數(shù)了。
我們正在做的是將錯誤傳遞給用戶,但只有在我們允許執(zhí)行用戶其余的代碼之后。通過使用process.nextTick(),我們就能保證apiCall()總是在剩余代碼之后且在允許事件循環(huán)繼續(xù)之前運行其回調(diào)。為了達到這個目的,JS調(diào)用堆棧允許展開,然后立即執(zhí)行提供的回調(diào),這樣就允許了開發(fā)者對process.nextTick()進行遞歸調(diào)用,而不會出現(xiàn)RangeError錯誤:超出v8的最大調(diào)用堆棧大小。
這種理念會造成一些潛在的困境。看下面的例子:
let bar;
// 這是一個異步簽名,但是調(diào)用了異步回調(diào)
function someAsyncApiCall(callback) { callback(); }
// 在someAsyncApiCall完成之前回調(diào)被調(diào)用了.
someAsyncApiCall(() => {
// 一旦someAsyncApiCall完成, bar不會指向任何值
console.log('bar', bar); // undefined
});
bar = 1;
用戶定義someAsyncApiCall()具有異步簽名,但它實際上是同步運行的。當(dāng)它被調(diào)用時,提供給someAsyncApiCall()的回調(diào)將在事件循環(huán)的相同階段被調(diào)用,因為someAsyncApiCall()實際上并不會異步執(zhí)行任何操作。 因此,回調(diào)會嘗試引用bar,即使它在作用域中可能沒有該變量,因為該腳本無法運行到完成狀態(tài)。
通過將回調(diào)放置在process.nextTick()中,腳本仍然具有運行到完成的能力,允許在調(diào)用回調(diào)之前對所有變量,函數(shù)等進行初始化。 它還具有不允許事件循環(huán)繼續(xù)的優(yōu)點。 在事件循環(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', () => {});
當(dāng)只有一個端口被傳遞時,該端口被立即綁定。 所以,'listening'回調(diào)可以立即被調(diào)用。 問題是.on('listening')回調(diào)不會在那個時候設(shè)置。
為了解決這個問題,'listening'事件在nextTick()中排隊等待腳本運行完成。 這允許用戶設(shè)置他們想要的任何事件處理程序。
process.nextTick() vs setImmediate()
正如用戶擔(dān)心的,這里有兩個方法很相似,但他們的名稱有點讓人困惑。
process.nextTick()在同一階段立即觸發(fā)
setImmediate()觸發(fā)后面的迭代或者事件循環(huán)的“tick”
實質(zhì)上,名稱應(yīng)該交換一下。 process.nextTick()比setImmediate()更快立即觸發(fā),但這是過去的人為因素,不太可能改變。修改這個轉(zhuǎn)換會使npm上大部分包垮掉。 每天都有更多的新模塊被添加,這意味著我們每多等一天,就會發(fā)生更多潛在損害。雖然他們很讓人混淆,但名字本身不會改變。
我們建議開發(fā)者在所有情況都使用setImmediate(),因為這更容易理解。(而且使得代碼在更多環(huán)境中兼容,比如瀏覽器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', () => { });
假設(shè)listen()在事件循環(huán)的開始處運行,但監(jiān)聽listening的回調(diào)放置在setImmediate()中。 除非傳遞主機名,否則綁定到端口將立即發(fā)生。要繼續(xù)進行事件循環(huán),它必須進入輪詢階段,這意味著收到連接并非不可能,從而允許在監(jiān)聽事件之前觸發(fā)連接事件。
另一個例子就是運行一個函數(shù)的構(gòu)造器,假設(shè)該構(gòu)造函數(shù)繼承于EventEmitter,并且在構(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!');
});
譯者補充
1、node的核心思想就是異步,異步的實現(xiàn)是基于一個C庫--libuv,這個庫其實是又一層封裝,針對windows和nix兩種系統(tǒng)進行了不同的處理。
2、Node的I/O異步
I/O操作主要包括http請求,文件讀取等。下圖就是整個異步I/O流程:

這里有三個概念:請求對象,IO觀察者,IOCP,線程池。IOCP,是Windows的內(nèi)核對象,nix是通過其他方法模擬實現(xiàn)。以下摘自wikipedia:
輸入輸出完成端口(Input/Output Completion Port,IOCP), 是支持多個同時發(fā)生的異步I/O操作的應(yīng)用程序編程接口。
原理:通常的辦法是,線程池中的工作線程的數(shù)量與CPU內(nèi)核數(shù)量相同,以此來最小化線程切換代價。一個IOCP對象,在操作系統(tǒng)中可關(guān)聯(lián)著多個Socket和(或)文件控制端。 IOCP對象內(nèi)部有一個先進先出(FIFO)隊列,用于存放IOCP所關(guān)聯(lián)的輸入輸出端的服務(wù)請求完成消息。請求輸入輸出服務(wù)的進程不接收IO服務(wù)完成通知,而是檢查IOCP的消息隊列以確定IO請求的狀態(tài)。 (線程池中的)多個線程負(fù)責(zé)從IOCP消息隊列中取走完成通知并執(zhí)行數(shù)據(jù)處理;如果隊列中沒有消息,那么線程阻塞掛起在該隊列。這些現(xiàn)成從而實現(xiàn)了負(fù)載均衡。
3、Node的非I/O異步
非I/O的異步操作包括:setTimeout(), setInterval(), process.nextTick(), setImmediate()。
以setTimeout()行為為例:

結(jié)合譯文中那張時間循環(huán)階段圖,I/O的異步是在I/O callbacks階段,setTimeout()和setInterval()是在timer階段,事件循環(huán)中每一次循環(huán)會經(jīng)過那幾個階段,在I/O callbacks階段中主要是I/O觀察者接收通知獲取其回調(diào)函數(shù)及請求結(jié)果,然后在此階段執(zhí)行回調(diào)函數(shù)。timer階段主要是檢查由定時器放入的handles是否到達時間,從而執(zhí)行回調(diào)。
對于process.nextTick()和setImmediate(), 前者是idle觀察者,后者是check觀察者,在每一次循環(huán)中,idle觀察者先于I/O觀察者,I/O觀察者先于check觀察者。同時,process.nextTick()的回調(diào)保存在一個數(shù)組中,每次循環(huán)會將數(shù)組中的回調(diào)全部執(zhí)行完,而setImmediate()的回調(diào)保存在鏈表中,每次循環(huán)只執(zhí)行鏈表中的一個回調(diào)節(jié)點。
執(zhí)行順序?qū)嵗?/p>
const fs = require('fs');
function someAsyncOperation(callback) {
// Assume this takes 95ms to complete
const startCallback = Date.now();
fs.readFile('/koa.js', () => {
console.log(`${Date.now() - startCallback}ms readfile`);
});
callback();
}
const timeoutScheduled = Date.now();
setTimeout(() => {
const delay = Date.now() - timeoutScheduled;
console.log(`${delay}ms have passed since I was scheduled`);
}, 100);
process.nextTick(() => {
console.log('next tick1');
});
process.nextTick(() => {
console.log('next tick2');
});
someAsyncOperation(() => {
setImmediate(() => {
console.log('setImmediate');
process.nextTick(() => {
console.log('next tick');
})
});
});
運行結(jié)果:
next tick1
next tick2
2ms readfile
setImmediate
next tick
103ms have passed since I was scheduled