Node.js 事件迴圈,定時器和 process.nextTick()

語言: CN / TW / HK

什麼是事件迴圈

事件迴圈是 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 佇列來執行回撥。雖然每個階段都是特殊的,但通常情況下,當事件迴圈進入給定的階段時,它將執行特定於該階段的任何操作,然後執行該階段佇列中的回撥,直到佇列用盡或最大回調數已執行。當該佇列已用盡或達到回撥限制,事件迴圈將移動到下一階段,等等。

由於這些操作中的任何一個都可能排程_更多的_操作和由核心排列在輪詢階段被處理的新事件, 且在處理輪詢中的事件時,輪詢事件可以排隊。因此,長時間執行的回撥可以允許輪詢階段執行長於計時器的閾值時間。有關詳細資訊,請參閱 計時器輪詢 部分。

注意: 在 Windows 和 Unix/Linux 實現之間存在細微的差異,但這對演示來說並不重要。最重要的部分在這裡。實際上有七或八個步驟,但我們關心的是 Node.js 實際上使用以上的某些步驟。

階段概述

  • 定時器:本階段執行已經被 setTimeout()setInterval() 的排程回撥函式。
  • 待定回撥:執行延遲到下一個迴圈迭代的 I/O 回撥。
  • idle, prepare:僅系統內部使用。
  • 輪詢:檢索新的 I/O 事件;執行與 I/O 相關的回撥(幾乎所有情況下,除了關閉的回撥函式,那些由計時器和 setImmediate() 排程的之外),其餘情況 node 將在適當的時候在此阻塞。
  • 檢測setImmediate() 回撥函式在這裡執行。
  • 關閉的回撥函式:一些關閉的回撥函式,如:socket.on('close', ...)

在每次執行的事件迴圈之間,Node.js 檢查它是否在等待任何非同步 I/O 或計時器,如果沒有的話,則完全關閉。

階段的詳細概述

定時器

計時器指定可以執行所提供回撥的 閾值,而不是使用者希望其執行的確切時間。在指定的一段時間間隔後, 計時器回撥將被儘可能早地執行。但是,作業系統排程或其它正在執行的回撥可能會延遲它們。

注意輪詢 階段 控制何時定時器執行。

例如,假設您排程了一個在 100 毫秒後超時的定時器,然後您的指令碼開始非同步讀取會耗費 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() 尚未完成),因此它將等待剩下的毫秒數,直到達到最快的一個計時器閾值為止。當它等待 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() 對比 setTimeout()

setImmediate()setTimeout() 很類似,但是基於被呼叫的時機,他們也有不同表現。

  • 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 迴圈內呼叫,setImmediate 總是被優先呼叫:

// 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, 而不管事件迴圈的當前階段如何。這裡的一個_操作_被視作為一個從底層 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: 超過 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() 對比 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() 在事件迴圈開始時執行,但 listening 的回撥被放置在 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!');
});

來源:http://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/