Node.js中的事件循環(Event Loop),計時器(Timers)以及process.nextTick()

什麼是事件循環(Event Loop)?

事件環使得Node.js能夠執行非阻塞I/O 操做,只要有可能就將操做卸載到系統內核,儘管JavaScript是單線程的。node

因爲大多數現代(終端)內核都是多線程的,他們能夠處理在後臺執行的多個操做。 當其中一個操做完成時,內核會通知Node.js,以即可以將適當的回調添加到輪詢隊列poll queue中以最終執行。 咱們將在本主題後面進一步詳細解釋這一點。npm

事件循環:解釋

Node.js開始運行,它初始化事件環、處理提供的輸入腳本(或放入REPL,本文檔未涉及),這可能會使異步API調用,計劃定時器或調用process.nextTick(),而後開始處理事件循環。api

下圖顯示了事件循環的操做順序的簡化概述。瀏覽器

┌───────────────────────┐
┌─>│    timers(計時器)    │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │   idle, prepare 內部  │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │      poll(輪詢)      │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
注意:每一個方框將被稱爲事件循環的「階段」。

每一個階段都有一個執行回調的FIFO(First In First Out,先進先出)隊列。 雖然每一個階段都有其特定的方式,但一般狀況下,當事件循環進入給定階段時,它將執行特定於該階段的任何操做,而後在該階段的隊列中執行回調,直到隊列耗盡或回調的最大數量已執行。 當隊列耗盡或達到回調限制時,事件循環將移至下一個階段,依此類推。多線程

因爲這些操做中的任何一個均可以調度更多的操做,而且在輪詢階段處理的新事件由內核排隊,因此輪詢事件能夠在輪詢事件正在處理的同時排隊。 所以,長時間運行的回調可使輪詢階段的運行時間遠遠超過計時器的閾值。有關更多詳細信息,請參閱定時器和輪詢部分。異步

注意:Windows和Unix / Linux實現之間略有差別,但這對此演示並不重要。 最重要的部分在這裏。 實際上有七八個步驟,但咱們關心的那些 - Node.js實際使用的那些 - 就是上述那些。

階段概述

  • 定時器(timers):此階段執行由setTimeout()setInterval()調度的回調。
  • I / O 回調函數:執行幾乎全部的回調函數,除了關閉回調函數,定時器計劃的回調函數和setImmediate()
  • 閒置,準備(idle, prepare):只在Node內部使用。
  • 輪詢(poll):檢索新的I / O事件; 適當時節點將在此處阻斷進程。
  • 檢查(check):setImmediate()回調在這裏被調用。
  • 關閉回調(close callbacks):例如 socket.on('close',...)

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

階段詳情

定時器async

計時器指定閾值,以後能夠執行提供的回調,而不是人們但願執行的確切時間。 定時器回調將在指定的時間事後,按照預約的時間運行; 可是,操做系統調度或其餘回調的運行可能會延遲它們。函數

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

例如,假設您計劃在100 ms閾值後執行超時,那麼您的腳本將異步開始讀取須要95 ms的文件:oop

const fs = require('fs');

function someAsyncOperation(callback) {
  // 假設這個讀取將用耗時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
someAsyncOperation(() => {
  const startCallback = Date.now();

  // 執行一些可能耗時10ms的操做
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

當事件循環進入輪詢階段時,它有一個空隊列(fs.readFile()還沒有完成),所以它將等待剩餘的毫秒數,直到達到最快計時器的閾值。 當它等待95ms傳遞時,fs.readFile()完成讀取文件,而且須要10ms完成的回調被添加到輪詢隊列並執行。 當回調完成時,隊列中沒有更多的回調,因此事件循環會看到已經達到最快計時器的閾值,而後回到計時器階段以執行計時器的回調。 在這個例子中,你會看到被調度的定時器和它正在執行的回調之間的總延遲將是105ms。

注意:爲防止輪詢階段進入惡性事件循環,在中止輪詢以前, libuv(實現Node.js事件循環的C庫以及平臺的全部異步行爲)也有一個硬性最大值(取決於系統)來中止輪詢更多的事件。

I / O回調

此階段爲某些系統操做(如TCP錯誤類型)執行回調。 例如,若是嘗試鏈接時TCP套接字收到ECONNREFUSED,則某些* nix系統要等待報告錯誤。 這將排隊在I / O回調階段執行。

輪詢

此階段有兩個主要的功能:

  1. 執行已過期的定時器腳本;
  2. 處理輪詢隊列中的事件。

當事件循環進入輪詢階段而且沒有計時器時,會發生如下兩件事之一:

  • 若是輪詢隊列不爲空,則事件循環將遍歷其回調隊列,同步執行它們,直到隊列耗盡或達到系統相關硬限制。
  • 若是輪詢隊列爲,則會發生如下兩件事之一:

    1)若是腳本已經過setImmediate()進行調度,則事件循環將結束輪詢階段並繼續執行檢查階段以執行這些預約腳本。
    2)若是腳本沒有經過setImmediate()進行調度,則事件循環將等待回調被添加到隊列中,而後當即執行它們。

一旦輪詢隊列爲空,事件循環將檢查已達到時間閾值的定時器。 若是一個或多個定時器準備就緒,則事件循環將回退到定時器階段以執行這些定時器的回調。

檢查check

此階段容許在輪詢階段結束後當即執行回調。 若是輪詢階段變得空閒而且腳本已經使用setImmediate()排隊,則事件循環可能會繼續檢查階段而不是等待。

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

一般,隨着代碼的執行,事件循環將最終進入輪詢階段,在那裏它將等待傳入的鏈接,請求等。可是,若是使用setImmediate()計劃了回調而且輪詢階段變爲空閒, 將結束並繼續進行檢查階段,而不是等待輪詢事件。

關閉回調

若是套接字或句柄忽然關閉(例如socket.destroy()),則在此階段將發出'close'事件。 不然它將經過process.nextTick()發出。

setImmediate()vs setTimeout()

setImmediate()setTimeout()是類似的,但取決於它們什麼時候被調用,其行爲方式不一樣。

  • setImmediate()用於在當前輪詢階段完成後執行腳本。
  • setTimeout()計劃腳本在通過最小閾值(以毫秒爲單位)後運行。

定時器執行的順序取決於它們被調用的上下文。 若是二者都是在主模塊內調用的,那麼時序將受到進程性能的限制(可能會受到計算機上運行的其餘應用程序的影響)。

例如,若是咱們運行如下不在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()的的主要優勢是,若是在I / O週期內進行調度,將始終在任何計時器以前執行setImmediate(),而無論有多少個計時器。

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()實際上並不會異步執行任何操做。 所以,回調會嘗試引用欄,即便它在範圍中可能沒有該變量,由於該腳本沒法運行到完成狀態。

經過將回調放置在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()觸發如下迭代或事件循環的「打勾」

實質上,名稱應該交換。 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);

  // 一旦處理程序被分配,使用nextTick來發出事件
  process.nextTick(() => {
    this.emit('event');
  });
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});
相關文章
相關標籤/搜索