Node.js 事件迴圈

事件迴圈是什麼?

事件迴圈是 Node.js 允許進行非阻塞 I/O 操作的機制 — 即使 JavaScript 是單線程的 — 它將操作分派到系統核心,以在可能的情況下進行。

由於大多數現代核心是多線程的,它們可以處理後台中的多個操作。當其中一個操作完成時,核心會通知 Node.js,以便將適當的回調添加到輪詢佇列中,最終將其執行。我們將在本主題的後續部分中詳細說明此過程。

事件迴圈解釋

當 Node.js 啟動時,它初始化事件迴圈,處理提供的輸入腳本(或進入 REPL,本文件不涵蓋此部分),可能會進行非同步 API 呼叫、排定計時器,或呼叫 process.nextTick(),然後開始處理事件迴圈。

以下圖表顯示事件迴圈運作順序的簡化概述。

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

每個方框將被稱為事件迴圈的一個「階段」。

每個階段都有一個要執行的回調的 FIFO 隊列。雖然每個階段各有其特殊之處,但一般而言,當事件迴圈進入特定階段時,它將執行特定於該階段的任何操作,然後執行該階段隊列中的回調,直到隊列已耗盡或已執行最大數量的回調。當隊列已耗盡或已達到回調限制時,事件迴圈將移至下一階段,依此類推。

由於這些操作中的任何一個都可能排定更多操作,而在 poll 階段處理的新事件會被核心排入隊列,因此,在處理輪詢事件的同時,可能會排隊輪詢事件。因此,運行時間較長的回調可以使輪詢階段運行時間遠超過計時器的閾值。有關詳細信息,請參閱 計時器輪詢 部分。

Windows 和 Unix/Linux 實現之間存在輕微差異,但對於本示範並不重要。最重要的部分在這裡。實際上有七或八個步驟,但我們關心的 - Node.js 實際使用的步驟 - 是上述的步驟。

階段概觀

  • 計時器:此階段執行由 setTimeout()setInterval() 設定的回調函式。
  • 待處理回調:執行延遲到下一次循環迭代的 I/O 回調。
  • 閒置,準備:僅在內部使用。
  • 輪詢:檢索新的 I/O 事件;執行與 I/O 相關的回調(幾乎全部,除了關閉回調、計時器設定的回調以及 setImmediate());當適當時,節點將在此處阻塞。
  • 檢查:在此處調用 setImmediate() 回調。
  • 關閉回調:一些關閉回調,例如 socket.on('close', ...)

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

詳細階段

計時器

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

從技術上講,輪詢階段控制計時器何時被執行。

例如,假設您安排一個在 100 毫秒閾值之後執行的超時,然後您的腳本開始異步讀取一個需要 95 毫秒的文件。

const fs = require('node: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()尚未完成),因此它會等待直到最接近的計時器閾值被觸發的剩餘毫秒數。在等待期間過了95毫秒,fs.readFile()完成了文件的讀取,其花費10毫秒完成的回調被添加到輪詢隊列中並執行。當回調完成時,隊列中沒有更多的回調,因此事件循環將會檢視最接近計時器閾值已被觸發,然後返回計時器階段執行計時器的回調。在這個例子中,你會看到計時器被安排和其回調被執行之間的總延遲時間為105毫秒。

為了防止輪詢階段使事件循環陷入饑餓狀態,libuv(實現Node.js事件循環和平台所有異步行為的C庫)也有一個硬性最大值(系統相關),在達到此值之前將停止輪詢更多事件。

待處理的回調

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

輪詢

輪詢階段有兩個主要功能

  1. 計算應該阻塞並輪詢I/O的時間,然後
  2. 處理輪詢隊列中的事件。

當事件循環進入輪詢階段並且沒有計時器被安排時,會發生以下兩種情況之一

  • 如果輪詢隊列不是空的,事件循環將遍歷其回調隊列同步執行它們,直到隊列被耗盡,或者達到系統相關的硬限制。

  • 如果輪詢隊列是空的,將發生另外兩種情況之一

    • 如果腳本已經通過setImmediate()排定,事件迴圈將結束輪詢階段,並繼續到檢查階段以執行這些已排定的腳本。

    • 如果腳本通過setImmediate()排定,事件迴圈將等待回調被添加到佇列中,然後立即執行它們。

一旦輪詢佇列為空,事件迴圈將檢查已達到時間閾值的計時器。如果一個或多個計時器已準備就緒,事件迴圈將返回到計時器階段以執行這些計時器的回調。

檢查

此階段允許在輪詢階段完成後立即執行回調。如果輪詢階段變為空閒且消耗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');
});

但是,如果將這兩個調用移動到 I/O 循環中,即使有多個計時器,立即回調也始終先執行。

// timeout_vs_immediate.js
const fs = require('node:fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});

使用 setImmediate() 而不是 setTimeout() 的主要優勢是,如果在 I/O 循環內安排了 setImmediate(),它將在任何計時器之前獨立執行。

process.nextTick()

理解 process.nextTick()

您可能已經注意到,在圖表中並未顯示 process.nextTick(),儘管它是異步 API 的一部分。這是因為 process.nextTick() 在技術上並不是事件循環的一部分。相反,在當前操作完成後,將處理 nextTickQueue,而不管事件循環的當前階段是什麼。這裡,一個操作被定義為從底層的 C/C++ 處理程序的轉換,以及處理需要執行的 JavaScript。

回顧我們的圖表,每次在特定階段調用 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: Maximum call stack size exceeded from 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 hasn't completed, bar hasn't been assigned any value
  console.log('bar', bar); // undefined
});

bar = 1;

用戶定義了具有異步簽名的 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(),因為它更容易理解。

為什麼要使用 process.nextTick()

主要有兩個原因

  1. 允許使用者處理錯誤,清理任何不再需要的資源,或者在事件迴圈繼續之前再次嘗試請求。

  2. 有時需要在呼叫堆疊已解除但事件迴圈繼續之前允許回調函數運行。

其中一個例子是為了符合使用者的期望。簡單的例子

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

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

假設 listen() 在事件迴圈開始時運行,但監聽回調被放置在一個 setImmediate() 中。除非傳遞了主機名,否則綁定到端口將立即發生。為了使事件迴圈繼續,它必須達到輪詢(poll) 階段,這意味著有可能在監聽事件之前已經接收到連接,從而觸發連接事件。

另一個例子是擴展一個 EventEmitter 並在構造函數內部發出事件

const EventEmitter = require('node:events');

class MyEmitter extends EventEmitter {
  constructor() {
    super();
    this.emit('event');
  }
}

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});

您無法立即從構造函數發出事件,因為腳本尚未處理到用戶分配回調函數給該事件的地方。因此,在構造函數內部,您可以使用 process.nextTick() 在構造函數完成後設置一個回調來發出事件,從而提供預期的結果

const EventEmitter = require('node:events');

class MyEmitter extends EventEmitter {
  constructor() {
    super();

    // use nextTick to emit the event once a handler is assigned
    process.nextTick(() => {
      this.emit('event');
    });
  }
}

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});