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

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)順序圖

:每一格稱為事件循環(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候這里會被阻斷。
checksetImmediate() 的回調(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!');
});
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,002評論 6 542
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,400評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,136評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,714評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 72,452評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,818評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,812評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,997評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,552評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 41,292評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,510評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,035評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,721評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,121評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,429評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,235評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 48,480評論 2 379

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