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

AlexShan發表於2018-04-06

什麼是事件迴圈(Event Loop)?

事件環使得Node.js可以執行非阻塞I/O 操作,只要有可能就將操作解除安裝到系統核心,儘管JavaScript是單執行緒的。

由於大多數現代(終端)核心都是多執行緒的,他們可以處理在後臺執行的多個操作。 當其中一個操作完成時,核心會通知Node.js,以便可以將適當的回撥新增到輪詢佇列poll queue中以最終執行。 我們將在本主題後面進一步詳細解釋這一點。

事件迴圈:解釋

Node.js開始執行,它初始化事件環、處理提供的輸入指令碼(或放入REPL,本文件未涉及),這可能會使非同步API呼叫,計劃定時器或呼叫process.nextTick(),然後開始處理事件迴圈。

下圖顯示了事件迴圈的操作順序的簡化概述。

   ┌───────────────────────┐
┌─>│    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或定時器時清除關閉。

階段詳情

定時器

計時器指定閾值,之後可以執行提供的回撥,而不是人們希望執行的確切時間。 定時器回撥將在指定的時間過後,按照預定的時間執行; 但是,作業系統排程或其他回撥的執行可能會延遲它們。

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

例如,假設您計劃在100 ms閾值後執行超時,那麼您的指令碼將非同步開始讀取需要95 ms的檔案:

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!');
});
複製程式碼

相關文章