Node.js 事件循環、定時器和process.nextTick()

本文為譯文,英文原文

什么是事件循環

盡管JavaScript是單線程的,但通過盡可能將操作放到系統內核執行,事件循環允許Node.js執行非阻塞I/O操作。

由于現代大多數內核都是多線程的,因此它們可以處理在后臺執行的多個操作。 當其中一個操作完成時,內核會告訴Node.js,以便可以將相應的回調添加到 輪詢隊列 中以最終執行。 我們將在本主題后面進一步詳細解釋。

事件循環解釋

當Node.js啟動時,它初始化事件循環,處理提供的輸入腳本(或放入REPL,本文檔未涉及),這可能會進行異步API調用,調度計時器或調用process.nextTick(), 然后開始處理事件循環。

下圖顯示了事件循環操作順序的簡要概述。

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

注意:每個框都將被稱為事件循環的“階段”。

每個階段都要執行一個FIFO的回調隊列。 雖然每個階段都有其特殊的方式,但通常,當事件循環進入給定階段時,它將執行特定于該階段的任何操作,然后在該階段的隊列中執行回調,直到隊列耗盡或最大回調數量為止 。 當隊列耗盡或達到回調限制時,事件循環將移至下一階段,依此類推。

由于這些操作中的任何一個可以調度更多操作并且在輪詢階段中處理的新事件由內核排隊,因此輪詢事件可以在處理輪詢事件時排隊。 因此,長時間運行的回調可以允許輪詢階段運行的時間比計時器的閾值長得多。 有關詳細信息,請參閱計時器輪詢部分。

注意:Windows和Unix / Linux實現之間存在輕微差異,但這對于此演示并不重要。 最重要的部分在這里。 實際上有七到八個步驟,但我們關心的是 - Node.js實際使用的那些 - 是上面那些。

階段概述

  • timer : 此階段執行setTimeout()setInterval()調度的回調
  • pending callbacks : 執行延遲到下一個循環迭代的I/O回調
  • idle, prepare : 只用于內部
  • poll : 檢索新的I/O事件; 執行與I/O相關的回調(幾乎所有回調都是帶有異常的close callbackstimerssetImmediate()調度的回調); node將在適當的時候阻塞在這里
  • check : 這里調用setImmediate()回調函數
  • close callbacks : 一些 close callbacks, 例如. socket.on('close', ...)

在事件循環的每次運行之間,Node.js檢查它是否在等待任何異步I / O或定時器,如果沒有,則關閉。

階段細節

定時器(timer)

計時器在一個回調執行完之后指定閾值,而不是人們希望的確切時間去執行。 定時器回調將在指定的時間過去后盡早安排; 但是,操作系統調度或其他回調的運行可能會延遲它們。

注意:從技術上講,輪詢階段控制何時執行定時器。

例如,假設您計劃在100毫秒后執行timeout,然后您的腳本將異步讀取一個耗時95毫秒的文件:

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
  }
});

當事件循環進入輪詢階段時,它有一個空隊列(fs.readFile()尚未完成),因此它將等待剩余的ms數,直到達到最快的計時器閾值。 當它等待95毫秒傳遞時,fs.readFile()完成讀取文件,并且其完成需要10毫秒的回調被添加到輪詢隊列并執行。 當回調結束時,隊列中不再有回調,因此事件循環將看到已達到最快定時器的閾值,然后回繞到定時器階段以執行定時器的回調。 在此示例中,您將看到正在調度的計時器與正在執行的回調之間的總延遲將為105毫秒。

注意:為了防止輪詢階段使事件循環挨餓,libuv(實現Node.js事件循環的C庫和平臺的所有異步行為)在停止輪詢之前也為事件提供了固定的最大值(取決于系統)。

等待回調(pending callbacks)

此階段執行某些系統操作(例如TCP錯誤類型)的回調。 例如,如果TCP套接字在嘗試連接時收到 ECONNREFUSED,則某些*nix系統希望等待報告錯誤。 這將排隊等待在等待回調階段執行。

輪詢(poll)

輪詢階段有兩個主要功能:

  1. 計算它阻塞和輪詢I / O的時間,然后
  2. 處理輪詢隊列中的事件。

當事件循環進入輪詢階段并且沒有定時器調度時,將發生以下兩種情況之一:

  • 如果輪詢隊列不為空,則事件循環將遍歷回調隊列并且同步執行,直到隊列已執行完或者達到系統相關的固定限制。

  • 如果輪詢隊列為空,則會發生以下兩種情況之一:

    1. 如果setImmediate()已調度腳本,則事件循環將結束輪詢階段并繼續執行檢查階段以執行這些調度腳本。

    2. 如果setImmediate()尚未調度腳本,則事件循環將等待將回調添加到隊列,然后立即執行它們。

檢查(check)

此階段允許在輪詢階段完成后立即執行回調。 如果輪詢階段變為空閑并且腳本已使用setImmediate()排隊,則事件循環可以繼續到檢查階段而不是等待。

setImmediate()實際上是一個特殊的計時器,它在事件循環的一個單獨階段運行。 它使用libuv API來調度在輪詢階段完成后執行的回調。

通常,在執行代碼時,事件循環最終會到達輪詢階段,它將等待傳入連接,請求等。但是,如果已使用setImmediate()調度回調并且輪詢階段變為空閑,則 將結束并繼續檢查階段,而不是等待輪詢事件。

關閉回調(close callbacks)

如果套接字或句柄突然關閉(例如socket.destroy()),則在此階段將發出'close'事件。 否則它將通過process.nextTick()發出。

setImmediate() vs setTimeout()

setImmediatesetTimeout()類似,但根據它們的調用時間以不同的方式運行。

  • setImmediate()用于在當前輪詢階段完成后執行腳本。
  • setTimeout()計劃在經過最小閾值(以ms為單位)后運行的腳本。

執行定時器的順序將根據調用它們的上下文而有所不同。 如果從主模塊中調用兩者,則時間將受到進程性能的限制(可能受到計算機上運行的其他應用程序的影響)。

例如,如果我們運行不在I / O周期內的以下腳本(即主模塊),則執行兩個定時器的順序是不確定的,因為它受進程性能的約束:

// 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周期內移動兩個調用,則始終首先執行立即回調:

// 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()的主要優點是setImmediate()將始終在任何定時器之前執行(如果在I / O周期內調度),與存在多少定時器無關。

process.nextTick()

理解process.nextTick()

您可能已經注意到process.nextTick()沒有顯示在圖中,即使它是異步API的一部分。 這是因為process.nextTick()在技術上不是事件循環的一部分。 相反,nextTickQueue將在當前操作完成后處理,而不管事件循環的當前階段如何。

回顧一下我們的圖表,無論何時在給定階段調用process.nextTick(),傳遞給process.nextTick()的所有回調都將在事件循環繼續之前得到解決。 這可能會產生一些不好的情況,因為它允許您通過進行遞歸的process.nextTick()調用來“餓死”您的I / O,這會阻止事件循環到達輪詢階段。

為什么會被允許?

為什么這樣的東西會被包含在Node.js中? 其中一部分是一種設計理念,其中API應該始終是異步的,即使它不是必須的。 以此代碼段為例:

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

這段代碼進行參數檢查,如果不正確,它會將錯誤傳遞給回調。 最近更新的API允許將參數傳遞給process.nextTick(),允許它將回調后傳遞的任何參數作為參數傳播到回調,因此您不必嵌套函數。

我們正在做的是將錯誤傳回給用戶,但只有在我們允許其余的用戶代碼執行之后。 通過使用process.nextTick(),我們保證apiCall()始終在用戶代碼的其余部分之后和允許事件循環繼續之前運行其回調。 為了實現這一點,允許JS調用堆棧展開然后立即執行提供的回調,這允許一個人對process.nextTick()進行遞歸調用而不會達到RangeError:超出v8的最大調用堆棧大小。

這種理念可能會導致一些潛在的問題。 以此片段為例:

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()定義為具有異步簽名,但它實際上是同步操作的。 調用它時,在事件循環的同一階段調用提供給someAsyncApiCall()的回調,因為someAsyncApiCall()實際上不會異步執行任何操作。 因此,回調嘗試引用bar,即使它在范圍內可能沒有該變量,因為該腳本無法運行完成。

通過將回調放在process.nextTick()中,腳本仍然能夠運行完成,允許在調用回調之前初始化所有變量,函數等。 它還具有不允許事件循環繼續的優點。 在允許事件循環繼續之前,向用戶警告錯誤可能是有用的。 以下是使用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'回調。 問題是那時候不會設置.on('listening')回調。

為了解決這個問題,'listening'事件在nextTick()中排隊,以允許腳本運行完成。 這允許用戶設置他們想要的任何事件處理程序。

process.nextTick() vs setImmediate()

就用戶而言,我們有兩個類似的調用,但它們的名稱令人困惑。

  • process.nextTick()在同一階段立即觸發
  • setImmediate()在事件循環的后續迭代或'tick'中觸發

實質上,應該交換名稱。 process.nextTick()setImmediate()更快地觸發,但這是過去創造的,不太可能改變。 進行此切換會破壞npm上的大部分包。 每天都會添加更多新模塊,這意味著我們每天都在等待,更多的潛在破損發生。 雖然它們令人困惑,但自身的叫法不會改變。

我們建議開發人員在所有情況下都使用setImmediate(),因為它更容易推理(并且它導致代碼與更廣泛的環境兼容,如瀏覽器JS。)

為什么要使用process.nextTick()

有兩個主要原因:

  1. 允許用戶處理錯誤,清除任何不需要的資源,或者在事件循環繼續之前再次嘗試請求。
  2. 有時需要允許回調在調用堆棧展開之后但在事件循環繼續之前運行。

一個例子是匹配用戶的期望。 簡單的例子:

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

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

假設listen()在事件循環開始時運行,但是監聽回調放在setImmediate()中。 除非傳遞主機名,否則將立即綁定到端口。 要使事件循環繼續,它必須達到輪詢階段,這意味著可能已經接收到連接的非零概率允許在偵聽事件之前觸發連接事件。

另一個例子是運行一個函數構造函數,比如繼承自EventEmitter,它想在構造函數中調用一個事件:

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!');
});

您無法立即從構造函數中發出事件,因為腳本將不會處理到用戶為該事件分配回調的位置。 因此,在構造函數本身中,您可以使用process.nextTick()來設置回調以在構造函數完成后發出事件,從而提供預期的結果:

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!');
});
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,663評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,125評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,506評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,614評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,402評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,934評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,021評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,168評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,690評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,596評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,784評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,288評論 5 357
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,027評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,404評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,662評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,398評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,743評論 2 370