[譯]Node.js中的事件迴圈,定時器和process.nextTick()

是熊大啊發表於2019-03-03

原文連結

什麼是事件迴圈

雖然js是單執行緒的,但是事件迴圈會盡可能地將解除安裝操作(offloading operations)託付給系統核心,讓node能夠執行非阻塞的I/O操作

由於大多數現代核心都是多執行緒的,因此它們可以處理在後臺執行的多個操作。當其中任意一個任務完成後,核心都會通知Node.js,以保證將相對應的回撥函式推入poll佇列中最終執行。稍後我們將在本文中詳細解釋這一點。

事件迴圈的定義

當Node.js服務啟動時,它就會初始化事件迴圈。每當處理到指令碼(或者是放置到REPL執行的程式碼,本文我們不提及)中非同步的API, 定時器,或者呼叫process.nextTick()都會觸發事件迴圈,

下圖簡單描述了事件迴圈的執行順序

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

注: 每個方框都是事件迴圈的一個階段

每個階段都有一個待執行回撥函式的FIFO佇列, 雖然每個階段都不盡相同,總體上說,當事件迴圈到當前階段時,它將執行特定於該階段的操作,然後就會執行被壓入當前佇列中的回撥函式, 直到佇列被清空或者達到最大的呼叫上限。 當佇列被清空或者達到最大的呼叫上限時,事件迴圈就會進入到下一階段,如此反覆。

因為任意階段的操作都有可能呼叫更多的任務和觸發新的事件,這些事件都最終會由核心推入poll階段,poll事件可以在執行事件的時候插入佇列。所以呼叫棧很深的回撥允許poll階段執行時間比定時器的閥值更久,詳細部分請檢視定時器和poll部分的內容。

注:Windows和Unix/Linux實現之間存在細微的差異,但這對於本文來說並不重要,最重要的部分在文中會一一指出。 實際上事件迴圈一共有七到八個步驟, 但是我們只需要關注Node.js中實際運用到的,也就是上文所訴的內容

階段概覽

  • timers: 這個階段將會執行setTimeout()setInterval()的回撥函式
  • pending callbacks: 執行延遲到下一個迴圈迭代的I/O回撥
  • idle, prepare: 只會在核心中呼叫
  • poll: 檢索新的I/O事件,執行I/O相關的回撥(除了結束回撥之外,幾乎所有的回撥都是由計時器和setimmediation()觸發的); node將會在合適的時候阻塞在這裡
  • check: setImmediate()的回撥將會在這裡觸發
  • close callbacks: 一些關閉事件的回撥, 比如socket.on("close", ...)

在任意兩個階段之間,Node.js都會檢查是否還有在等待中的非同步I/O事件或者定時器,如果沒有就會乾淨得關掉它。

階段的細節

timers

定時器將會在一個特定的時間之後執行相應的回撥,而不是在一個通過開發者設定預期的時間執行。定時器將會在超過設定時間後儘早地執行,然而作業系統的排程或者執行的其他回撥將會將之滯後。

注: 從技術上講,poll階段會控制定時器什麼時候執行

比如說,你設定了一個100ms過後執行的定時器,但是你的指令碼在剛開始時非同步讀取檔案耗費了95ms:

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

複製程式碼

當事件迴圈進入到poll階段,它將會宣告一個空的佇列(fs.readFile()還暫時沒有完成),所以它將會等待一段時間來儘早到達定時器的閥值。當等待了95ms過後,fs.readFile()結束讀取檔案的任務並且再花費10ms的時間去完成被推入poll佇列中的回撥,當回撥結束,此時在佇列中沒有其他回撥,這個時候事件迴圈將會看到定時器的閥值已經過了,並且是可以儘快執行的時機,這個時候回到timers階段去執行定時器的回撥。這樣來說,你將會看到定時器從開始排程到被執行間隔105ms。

注: 為了保證poll階段不出現輪訓飢餓,libuv(一個c語言庫,由他來實現Node.js的事件迴圈和所有平臺的非同步操作)會提供一個觸發最大值(取決於系統),在達到最大值過後會停止觸發更多事件。

pending callbacks

這個階段將會執行作業系統的一些回撥如同TCP的錯誤捕獲一樣。比如如果一個TCP 套接字接收到了ECONNREFUSED在嘗試建立連結的時候,一些*nix系統就會上報當前錯誤,這個上報的回撥就會被推入pending callback的執行佇列中去。

poll

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

  1. 計算什麼時候阻塞或者輪詢更多的I/O
  2. 執行在poll佇列中的回撥

當事件迴圈進入到poll階段並且沒有定時器在被排程中的時候,下面兩種情況中的一種會發生:

  • 當poll佇列不為空,事件迴圈將會遍歷它的佇列並且同步執行他們,直到佇列被清空或者達到系統執行回撥的上限
  • 如果poll佇列為空,將要發生的另外兩件事之一:
    • 如果系統排程過setImmediate(),那麼事件迴圈將會結束poll階段然後繼續到check階段去執行setImmediate()的回撥
    • 如果系統沒有排程過setImmediate(), 那麼事件迴圈將等待回撥被推入佇列,然後立即執行它

一旦poll階段佇列為空事件迴圈將會檢查是否到達定時器的閥值,如果有定時器準備好了,那麼事件迴圈將會回到timers階段去執行定時器的回撥

check

這個階段允許開發者在poll階段執行完成後立即執行回撥函式。 如果poll階段變為空閒狀態並且還有setImmediate()回撥,那麼事件迴圈將會直接來到check階段而不是繼續在poll階段等待

setImmediate()實際上是執行在事件迴圈各個分離階段的特殊定時器,它直接使用libuv的API去安排回撥在poll階段完成後執行

通常上來說,在執行程式碼時,事件迴圈最終會進入輪詢階段,等待傳入連線、請求等。但是,如果還有 setImmediate()回撥,並且輪詢階段變為空閒狀態,則它將結束並繼續到check階段而不是等待poll事件。

close callbacks

如果一個socket連線突然關閉(比如socket.destroy()),‘close’事件將會被推入這個階段的佇列中,否則它將通過process.nextTick()觸發。

setImmediate()和setTimeout()有什麼不同

setImmediatesetTimeout相似,但是他們在被呼叫的時機上是不同的。

  • setImmediate被設計在當前poll階段完成後執行
  • 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迴圈中去,immediate總是會先執行。


// 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(),這樣就會阻止事件迴圈進入到poll階段

為什麼這種情況會被允許

為什麼這樣的事情會包含在 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 has completed, bar hasn`t been assigned any value
  console.log(`bar`, bar); // undefined
});

bar = 1;

複製程式碼

這裡有一個非同步簽名的someAsyncApiCall() 函式,但實際上它是同步執行的。當呼叫它時,提供給 someAsyncApiCall() 的回撥在同一階段呼叫事件迴圈,因為 someAsyncApiCall() 實際上並沒有非同步執行任何事情。因此,回撥嘗試引用 bar,即使它在範圍內可能還沒有該變數,因為指令碼無法按照預料中完成。

將回撥用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(),因為它更讓人理解(並且它導致程式碼與更廣泛的環境,如瀏覽器 JS 所相容。)

為什麼使用process.nextTick()

主要有兩個原因:

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

  • 有時在呼叫堆疊已解除但在事件迴圈繼續之前,必須允許回撥執行。

下面就是一個符合使用者預期的例子:


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

這裡並不能立即從建構函式中觸發event事件。因為在此之前使用者並沒有給event事件新增回撥。但是,在建構函式本身中可以使用 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!`);
});

複製程式碼

相關文章