【譯文】Node.js的事件循環(huán)(Event loop)、定時(shí)器(Timers)和 process.nextTick()

11原文:The Node.js Event Loop, Timers, and process.nextTick()

什么是事件循環(huán)?

事件循環(huán)通過(guò)將操作分給系統(tǒng)內(nèi)核來(lái)處理使得使用單線程的 JavaScript 的 Node.js 可以進(jìn)行無(wú)阻塞 I/O 操作。

由于大部分現(xiàn)代內(nèi)核都是多線程的,所以可以在后臺(tái)同時(shí)處理多個(gè)操作。當(dāng)有操作完成時(shí),內(nèi)核會(huì)告訴 Node.js,Node.js 將合適的回調(diào)加入輪詢隊(duì)列等待被執(zhí)行。

事件循環(huán)解析

在 Node.js 啟動(dòng)的時(shí)候,一步步地做了:初始化事件循環(huán),處理可能包含異步 API 調(diào)用的輸入腳本(用戶代碼)(或進(jìn)入 REPL,這里不講 REPL),調(diào)度定時(shí)器,或者調(diào)用 process.nextTick() 。然后開始處理事件循環(huán)。

下圖顯示了事件循環(huán)的操作順序的簡(jiǎn)化概覽。

事件循環(huán)順序圖

:每一格稱為事件循環(huán)的一個(gè)階段。

每一階段都有一個(gè)先進(jìn)先出的待執(zhí)行任務(wù)隊(duì)列。而在每一階段內(nèi)部有自己的執(zhí)行方法,也就是說(shuō),當(dāng)進(jìn)入其中一個(gè)階段時(shí),會(huì)執(zhí)行任何該階段自己特定的操作,然后才執(zhí)行在該階段的隊(duì)列中的回調(diào),直到隊(duì)列里的回調(diào)都執(zhí)行完了或執(zhí)行的次數(shù)達(dá)到最大限制。當(dāng)隊(duì)列耗盡或執(zhí)行的次數(shù)達(dá)到最大限制時(shí),事件循環(huán)進(jìn)入下一個(gè)階段,如此循環(huán)。

由于這些操作可以安排更多別的操作,并且在輪詢階段處理的新事件都是由內(nèi)核入隊(duì)的,則輪詢事件可以在處理輪詢事件時(shí)入隊(duì)。從而長(zhǎng)時(shí)間運(yùn)行的回調(diào)可以讓輪詢階段運(yùn)行時(shí)間長(zhǎng)于定時(shí)器的閾值。詳見后文。

: Windows 和 Unix/Linux 之間對(duì)這些的實(shí)現(xiàn)存在細(xì)微差別,但對(duì)于此文而言并不重要。實(shí)際上有七到八個(gè)步驟,但是我們關(guān)心的、Node.js 真正用到的這里都講到了。

事件循環(huán)階段一覽

定時(shí)器:這一階段執(zhí)行由 setTimeout()setInterval() 設(shè)置的回調(diào)。
I/O 回調(diào):執(zhí)行除關(guān)閉回調(diào)、定時(shí)器調(diào)度的回調(diào)和 setImmediat() 以外的幾乎所有的回調(diào)。
ide,prepare:僅內(nèi)部使用。
輪詢:獲取新的 I/O 事件;適當(dāng)?shù)臅r(shí)候這里會(huì)被阻斷。
checksetImmediate() 的回調(diào)。
關(guān)閉事件回調(diào):如 socket.on('close', ...) 的回調(diào)。

在事件循環(huán)的每次運(yùn)行之間, Node.js 會(huì)檢查是否在等待任何異步 I/O 或定時(shí)器,如果兩個(gè)都沒有就自動(dòng)關(guān)閉。

事件循環(huán)階段詳解

定時(shí)器

定時(shí)器在給出的回調(diào)后面指定了等待多長(zhǎng)時(shí)間后執(zhí)行這個(gè)回調(diào),而事實(shí)上實(shí)際執(zhí)行這個(gè)任務(wù)的等待時(shí)間往往大于指定的等待時(shí)間。定時(shí)器給出的回調(diào)任務(wù)在達(dá)到等待時(shí)間后會(huì)盡可能快地被執(zhí)行;然而,操作系統(tǒng)調(diào)度或運(yùn)行其他回調(diào)任務(wù)會(huì)使應(yīng)被執(zhí)行的任務(wù)被延遲執(zhí)行。

:技術(shù)上來(lái)說(shuō),輪詢階段控制定時(shí)器什么時(shí)候可以執(zhí)行回調(diào)。

例如,先指定了一個(gè)閾值為 100ms 的定時(shí)器,然后開始異步讀取一個(gè)需要用時(shí) 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)進(jìn)入輪詢階段,任務(wù)隊(duì)列還是空的( fs.readFile() 還沒執(zhí)行完),所以這里開始等待 fs.readFile() 執(zhí)行完或有一個(gè)定時(shí)器達(dá)到閾值。這里 95ms 更快到達(dá),即文件先讀完然后回調(diào)添加到輪詢隊(duì)列并開始執(zhí)行,而該回調(diào)任務(wù)需要花費(fèi) 10ms 來(lái)執(zhí)行。在執(zhí)行完這個(gè)任務(wù)以后進(jìn)入定時(shí)器階段時(shí)發(fā)現(xiàn)有定時(shí)器閾值到了,可以開始執(zhí)行了,然后開始執(zhí)行這個(gè)定時(shí)器回調(diào)。在這個(gè)例子里,實(shí)際等待時(shí)間比指定的等待時(shí)間多了 5ms。

:為了防止輪詢階段獨(dú)占事件循環(huán)而使得其它階段一直無(wú)法被執(zhí)行, libuv (一個(gè)
實(shí)現(xiàn)了 Node.js 事件循環(huán)機(jī)制和所有異步行為的 C 庫(kù))在停止對(duì)更多事件的輪詢之前也有一個(gè)依賴于系統(tǒng)的最大值。

I/O 回調(diào)

這一階段執(zhí)行一些如 TCP 錯(cuò)誤類型這類的系統(tǒng)操作回調(diào)。舉例來(lái)說(shuō),如果一個(gè)正在嘗試連接的 TCP 收到了 ECONNREFUSED ,一些系統(tǒng)要報(bào)告這個(gè)錯(cuò)誤,此時(shí)要等待時(shí)機(jī),這時(shí)這個(gè)錯(cuò)誤報(bào)告就被排入 I/O 回調(diào)的隊(duì)列里。

輪詢

這個(gè)階段有兩個(gè)主要的功能:
1、為閾值已經(jīng)到了的定時(shí)器執(zhí)行一些腳本后進(jìn)入2。
2、處理隊(duì)列里的事件。
當(dāng)事件循環(huán)進(jìn)入這個(gè)階段且沒有定時(shí)器時(shí),則:

  • 如果輪詢回調(diào)隊(duì)列里不為空,事件循環(huán)將遍歷回調(diào)隊(duì)列,同步執(zhí)行隊(duì)列里的任務(wù)直到隊(duì)列空了或達(dá)到依賴于系統(tǒng)的最大值。
  • 如果隊(duì)列為空,則:
    • 如果存在 setImmediate() ,事件循環(huán)將結(jié)束這個(gè)階段進(jìn)入 check 階段來(lái)執(zhí)行 setImmediate() 的回調(diào)。
    • 如果不存在 setImmediate() ,事件循環(huán)將等待輪詢階段的回調(diào)入隊(duì),然后立刻執(zhí)行這些回調(diào)。

一旦輪詢隊(duì)列為空,事件循環(huán)將檢查是否有閾值到達(dá)了的定時(shí)器,如果有,事件循環(huán)將返回到定時(shí)器階段來(lái)執(zhí)行這些定時(shí)器的回調(diào)。

check

這個(gè)階段允許我們?cè)谳喸冸A段完成后立刻執(zhí)行一些回調(diào)。如果輪詢階段變?yōu)榭臻e,并且有 setImmediate() 的回調(diào)排隊(duì),那么事件循環(huán)可能會(huì)繼續(xù)進(jìn)入 check 階段,而不是等待輪詢回調(diào)入隊(duì)。

setImmediate() 實(shí)際上是一個(gè)特殊的定時(shí)器,它在事件循環(huán)的一個(gè)單獨(dú)的階段中運(yùn)行。在輪詢階段完成之后,它使用一個(gè) libuv API 調(diào)度回調(diào)執(zhí)行。

一般來(lái)說(shuō),隨著代碼執(zhí)行,事件循環(huán)最終會(huì)到達(dá) check 階段,在該階段等待一個(gè)傳入連接、請(qǐng)求等。然而如果有一個(gè)回調(diào)里調(diào)用了 setImmediate() 且輪詢階段空閑,此時(shí)將進(jìn)入 check 階段而不是等待輪詢階段的回調(diào)任務(wù)。

關(guān)閉事件回調(diào)

如果一個(gè) socket 或 handle 突然關(guān)閉(如:socket.destroy() ),這個(gè)階段將發(fā)送 close 事件。否則這個(gè) close 事件將通過(guò) process.nextTick() 發(fā)送。

setImmediate() VS setTimeout()

setImmediate()setTimeout() 很像,區(qū)別在于執(zhí)行的時(shí)間點(diǎn):

  • setImmediate() 在當(dāng)前輪詢階段完成后執(zhí)行。
  • setTimeout() 在達(dá)到所定的時(shí)間(單位:ms)以后被執(zhí)行。

它們被執(zhí)行的順序依賴于它們?cè)谏舷挛闹械奈恢谩H绻@兩個(gè)都是在主模塊內(nèi)部調(diào)用的,那么定時(shí)器將受到進(jìn)程性能的限制(受運(yùn)行在這個(gè)機(jī)器上的其它應(yīng)用程序影響)。

例如,如果我們?cè)谝粋€(gè) I/O 循環(huán)之外(即主模塊)運(yùn)行以下代碼,這兩個(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è)的調(diào)用放在一個(gè) 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)點(diǎn)是:如果是在一個(gè) I/O 循環(huán)里調(diào)用, setImmediate() 將總是在任何一個(gè)定時(shí)器之前被執(zhí)行。

process.nextTick()

理解 process.nextTick()

也許你注意到了盡管 process.nextTick() 是異步 API 的一部分,但是它不在之前的循環(huán)圖里。這是因?yàn)樵诩夹g(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ù)之前得到解決。這會(huì)造成一些不好的情況,因?yàn)橥ㄟ^(guò)遞歸調(diào)用 process.nextTick() 可以讓 I/O 一直處于等待狀態(tài),這同時(shí)也讓事件循環(huán)到不了輪詢階段。

為何 process.nextTick() 還存在

為什么像這樣的一個(gè)方法還存在于 Node.js 中呢?一部分是因?yàn)檫@是一種設(shè)計(jì)理念,即 API 即使在不需要的地方也應(yīng)該始終是異步的。見下面這段代碼:

function apiCall(arg, callback) {
  if (typeof arg !== 'string')
    return process.nextTick(callback,
                            new TypeError('argument should be string'));
}

這里進(jìn)行了一個(gè)參數(shù)檢查,如果參數(shù)不正確就返回一個(gè)錯(cuò)誤給回調(diào)。這個(gè) API 最近更新了,變成允許傳遞參數(shù)給 process.nextTick() ,這使得在將傳入的回調(diào)當(dāng)做參數(shù)傳給 process.nextTick() 后還可以傳任何別的參數(shù),這樣就不用嵌套函數(shù)了。

我們要做的是在執(zhí)行了調(diào)用者其余的代碼(在
apiCall 以外的)以后返回一個(gè)錯(cuò)誤給調(diào)用者。通過(guò)使用 process.nextTick() 保證了 apiCall() 的回調(diào)永遠(yuǎn)能在執(zhí)行完調(diào)用者其它的代碼以后且在事件循環(huán)繼續(xù)之前被執(zhí)行。為了實(shí)現(xiàn)這一點(diǎn), JS 調(diào)用??梢员会尫?,然后立刻執(zhí)行給出的回調(diào),這個(gè)回調(diào)可以遞歸調(diào)用 process.nextTick() 而不會(huì)得到 RangeError: Maximum call stack size exceeded from v8 錯(cuò)誤。

這個(gè)設(shè)計(jì)理念可以導(dǎo)致一些潛在的問題??匆韵乱欢未a:

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)該有一個(gè)異步行為,但是事實(shí)上是同步操作的。當(dāng)它被調(diào)用的時(shí)候,傳入的回調(diào)在事件循環(huán)的同一階段里被調(diào)用,因?yàn)?someAsyncApiCall() 并沒有做任何異步的事情。因此,當(dāng)傳入的回調(diào)要引用 bar 的時(shí)候 bar 被賦值的那一步還沒被執(zhí)行到,此時(shí) bar 還是 undefined 。

通過(guò)在回調(diào)里用 process.nextTick() 來(lái)替代就能讓代碼運(yùn)行到最后然后才去執(zhí)行回調(diào)。還有一個(gè)優(yōu)點(diǎn)是讓事件循環(huán)不能繼續(xù)。這可以用于在事件循環(huán)繼續(xù)之前給出一個(gè)錯(cuò)誤提示。以下代碼是使用了 process.nextTick() 以后的:

let bar;

function someAsyncApiCall(callback) {
  process.nextTick(callback);
}

someAsyncApiCall(() => {
  console.log('bar', bar); // 1
});

bar = 1;

這是另外一個(gè)實(shí)際會(huì)用到的例子:

const server = net.createServer(() => {}).listen(8080);

server.on('listening', () => {});

這里綁定一個(gè)端口時(shí)該端口又只被這里綁定就會(huì)立刻綁定好,所以 listening 的回調(diào)可以被立刻執(zhí)行,問題是 .on('listening') 在這個(gè)時(shí)間點(diǎn)可能還沒設(shè)置好。
要正確獲取到這個(gè) listening 事件的話要使用一個(gè) nextTick() 放在 listen 外層,讓主模塊代碼先運(yùn)行完再執(zhí)行這個(gè) listen

process.nextTick() vs setImmediate()

這兩個(gè)方法的名字容易引起誤解。

  • process.nextTick() 在同一階段立刻執(zhí)行
  • setImmediate() 在事件循環(huán)的下一迭代或 tick 里執(zhí)行
    從本質(zhì)上來(lái)看它們的名字應(yīng)該交換下比較好。 process.nextTick()setImmediate() 更 ‘immediate’,但這是過(guò)去定好的現(xiàn)在不可能再改了。如果真要交換的話可能破壞一大部分的 npm 包。每天都有很多模塊加入 npm 里,這意味著我們每多等一天就有更多可能被影響的包出現(xiàn)。因此它們的名字不會(huì)改變。

我們建議開發(fā)者在所有情況下都使用 setImmediate() 而不是 process.nextTick() 因?yàn)?setImmediate() 更容易被理解(且?guī)?lái)更廣泛的兼容性,如瀏覽器 JS )。

為什么使用 process.nextTick()

有兩個(gè)原因:
1、讓用戶處理錯(cuò)誤,清理干凈不需要的資源,或可能在事件循環(huán)繼續(xù)之前重試一下。
2、有時(shí)需要在調(diào)用棧被釋放之后且在事件循環(huán)繼續(xù)之前運(yùn)行一些回調(diào)。
舉個(gè)簡(jiǎn)單的例子:

const server = net.createServer();
server.on('connection', (conn) => { });

server.listen(8080);
server.on('listening', () => { });

這里 listen() 在事件循環(huán)的一開始就執(zhí)行了,但是監(jiān)聽 listening 事件的回調(diào)在一個(gè) setImmediate() 里面。除非將主機(jī)名傳遞給這個(gè)端口,否則這些將立即發(fā)生。此時(shí)事件循環(huán)要繼續(xù)下去的話必須到達(dá)輪詢階段,這意味著需有一個(gè)連接在 listening 事件之前觸發(fā)。

另一個(gè)例子是運(yùn)行一個(gè)繼承了 EventEmitter 的構(gòu)造函數(shù),且想要在構(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)樵撃_本還沒處理到用戶為該事件指定回調(diào)的點(diǎn)。在構(gòu)造函數(shù)里面可以使用 process.nextTick() 來(lái)設(shè)置一個(gè)回調(diào)來(lái)在構(gòu)造函數(shù)完成后發(fā)出這個(gè)事件,這能得到預(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!');
});
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,428評(píng)論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,024評(píng)論 3 413
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人,你說(shuō)我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 175,285評(píng)論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)。 經(jīng)常有香客問我,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,548評(píng)論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,328評(píng)論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 54,878評(píng)論 1 321
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,971評(píng)論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,098評(píng)論 0 286
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,616評(píng)論 1 331
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,554評(píng)論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,725評(píng)論 1 369
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,243評(píng)論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 43,971評(píng)論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,361評(píng)論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,613評(píng)論 1 280
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 51,339評(píng)論 3 390
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,695評(píng)論 2 370

推薦閱讀更多精彩內(nèi)容