1. 程式人生 > >NodeJS官方文件中文版之《事件迴圈, 定時器和process.nextTick()》

NodeJS官方文件中文版之《事件迴圈, 定時器和process.nextTick()》

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

什麼是事件迴圈?

事件迴圈允許Node.js通過儘可能地分流對系統核心的操作, 來執行 非阻塞 的I/O操作, 即使JavaScript是單執行緒的.

大多數現代的系統核心都是多執行緒的, 他們在後臺可以處理多個同時執行的操作. 當其中一個操作完成時, 系統核心會通知Node.js, 然後與之相關的回撥函式會被加入到 poll佇列 並且最終被執行. 對此本文稍後會詳細解釋.

事件迴圈說明

當Node.js開始執行時, 它會初始化事件迴圈, 並執行提供給它的可能呼叫了非同步API, 設定定時器, 或呼叫了 process.nextTick()

的指令碼程式碼(或者進入互動式直譯器(REPL), 這種情況並未涵蓋在本文中), 然後開始處理事件迴圈.

下面的圖解展示了一個簡化後的事件迴圈操作順序概覽.

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

注意: 圖中的每個方框被稱作事件迴圈的一個”階段(phase)”

每個階段都有一個先進先出(FIFO)的佇列, 裡面存放著將要執行的回撥函式. 然而每個階段都有其特殊之處, 通常來講, 當事件迴圈進入了某個階段後, 它可以執行該階段特有的任意操作, 然後執行該階段的任務佇列中的回撥函式, 一直到佇列為空或已執行回撥的數量達到了允許的最大值. 當佇列為空或已執行回撥的數量達到了允許的最大值時, 事件迴圈會進入下一階段.

由於這些操作中的任意一個都可以排程 更多的 操作, 在 poll(輪詢) 階段處理的新事件被系統核心加入佇列, 當輪詢事件正在被處理時新的輪詢事件也可以被加入佇列. 因此, 長時間執行的回撥函式可以讓 poll

階段執行的時間比 timer(計時器) 的閾值長得多. 檢視 timerpoll 部分了解更多細節.

注意: 在Windows和Unix/Linux實現之間存在一點小小的差異, 但對本示例來說這並不重要. 最重要的部分都已列在這裡了. 實際上有7或8個階段, 但我們關心的和Node.js實際會用到的階段都已經列在了上面.

階段概覽

  • timers(定時器) : 此階段執行那些由 setTimeout()setInterval() 排程的回撥函式.

  • I/O callbacks(I/O回撥) : 此階段會執行幾乎所有的回撥函式, 除了 close callbacks(關閉回撥) 和 那些由 timerssetImmediate() 排程的回撥.

  • idle(空轉), prepare : 此階段只在內部使用

  • poll(輪詢) : 檢索新的I/O事件; 在恰當的時候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()還未完成), 所以它會等待剩餘的ms數一直到最近的定時器時間閾值之後. 當它等待了95ms之後, fs.readFile()完成了檔案讀取並且那個要耗時10ms才能完成的回撥被加入 poll 佇列並且執行. 當這個耗時10ms的回撥執行結束後, 佇列裡沒有回調了, 因此事件迴圈會發現最近的定時器時間閾值已經過去了, 然後它返回 timers 階段執行定時器回撥. 在這個例子中, 你會發現在設定定時器和該定時器回撥被執行之間的時間間隔為105ms.

注意: 為了防止 poll 階段阻塞事件迴圈, libuv(一個實現了Node.js事件迴圈和Node.js平臺所有非同步行為的C語言庫), 有一個嚴格的最大限制(這個值取決於作業系統), 在超過此限制後就會停止輪詢.

I/O callbacks

此階段執行一些系統操作(如各種TCP錯誤)的回撥. 舉個例子, 如果一個TCP socket在嘗試連線時收到 ECONNREFUSED 錯誤, 一些 *nix 系統會等待報告該錯誤. 這些操作會被新增到佇列並在 I/O callbacks 階段執行.

poll

poll 階段有兩個主要功能:

  1. 執行時間閾值已過去的定時器回撥, 然後
  2. 處理 poll 佇列中的事件

當事件迴圈進入 poll 階段並且 當前沒有定時器時, 以下兩種情況的其中一種將會發生:

  • 如果 poll 佇列 不是空的, 事件迴圈會遍歷佇列並同步地執行裡面的回撥函式, 一直到佇列變為空或者達到作業系統的限制(作業系統規定的連續呼叫回撥函式的數量的最大值).

  • 如果 poll 佇列時空的, 則以下兩種情況的其中一種將會發生:

    • 如果存在被 setImmediate() 排程了的回撥, 事件迴圈會結束 poll 階段並進入 check 階段執行那些被 setImmediate() 排程了的回撥.

    • 如果沒有任何被 setImmediate() 排程了的回撥, 事件迴圈會等待回撥函式被加入佇列. 一旦有回撥函式加入了佇列, 就立即執行他們.

一旦 poll 佇列變為空, 事件迴圈就檢查是否存在已經過了時間閾值的定時器. 如果存在, 時間迴圈將繞回到 timers 階段執行這些定時器回撥.

check

此階段允許開發者在 poll 階段完成後立即執行回撥函式. 如果 poll 階段變為空轉(idle)狀態並且已存在被 setImmediate() 加入佇列的回撥, 事件迴圈可能會進入 check 階段而非繼續等待.

setImmediate() 實際上是一個特殊的定時器, 它設定的回撥在事件迴圈的一個單獨的階段執行. 它使用了一個 libuv 庫的API, 這個API在 poll 階段完成之後才會執行回撥.

一般來講, 隨著程式碼的執行, 事件迴圈終究會進入 poll 階段, 在此階段事件迴圈會等待連線或請求等等. 然而, 如果存在已被 setImmediate() 設定的回撥並且 poll 階段變為空轉狀態, 事件迴圈就會停止空轉並進入 check 階段, 而不是一直等待 poll 事件.

close callbacks

如果一個socket或控制代碼被突然關閉(例如 socket.destroy()), 'close'事件會在此階段被觸發. 否則 'close'事件會通過 process.nextTick() 被觸發.

setImmediate() vs setTimeout()

setImmediate()setTimeout() 有些類似, 但呼叫的時機不同, 它們的行為方式也不同.

  • 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週期中, 那麼 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

相比於 setTimeout(), 使用 setImmediate() 的主要優點在於: 只要時在I/O週期內, 不管已經存在多少個定時器, setImmediate()設定的回撥總是在定時器回撥之前執行.

process.nextTick()

理解 process.nextTick()

或許你已經注意到 process.nextTick() 並沒有在上面的事件迴圈圖解中列出來, 即使它也是非同步API的一部分. 這是因為從技術上來講, process.nextTick() 不屬於事件迴圈的一部分. 事實是不管當前處於事件迴圈的哪個階段, 在當前操作完成後, nextTickQueue 佇列就會被處理.

回頭看事件迴圈圖解, 任何時候在給定的階段呼叫 process.nextTick() 時, 所有傳入 process.nextTick() 的回撥都會在事件迴圈繼續之前被執行. 這會導致糟糕的情況, 因為它允許開發者通過遞迴呼叫 process.nextTick() 來阻塞I/O操作, 這也使事件迴圈無法到達 poll 階段.

為什麼要允許這種情況存在?

為什麼Node.js允許這類情況存在? 一部分原因是它的設計哲學: API應該始終是非同步的, 即使在不必要的地方也是如此. 以下面的程式碼片段為例:

function apiCall(arg, callback) {
  if (typeof arg !== 'string')
    return process.nextTick(callback,
                            new TypeError('argument should be string'));
}

這段程式碼會檢查引數型別, 如果型別不正確就會把一個異常傳入callback函式. 最近API更新之後, 允許向 process.nextTick() 傳遞多個引數, 在callback函式之後的那些引數將會作為callback函式的引數, 這樣就無需巢狀函數了.

我們所做的是傳遞一個異常給使用者, 但只有在我們允許執行剩餘的使用者程式碼時才會傳遞這個異常. 通過使用 process.nextTick(), 可以確保 apiCall() 總是在剩餘的使用者程式碼之後並且在事件迴圈被允許進入下一階段之前執行callback函式. 為了實現這一點, JS呼叫棧被允許進行棧展開(譯者注: stack unwinding, 丟擲異常時,將暫停當前函式的執行,開始查詢匹配的catch子句。首先檢查throw本身是否在try塊內部,如果是,檢查與該try相關的catch子句,看是否可以處理該異常。如果不能處理,就退出當前函式,並且釋放當前函式的記憶體並銷燬區域性物件,繼續到上層的呼叫函式中查詢,直到找到一個可以處理該異常的catch。這個過程稱為棧展開, 即stack unwinding。當處理該異常的catch結束之後,緊接著該catch之後的點繼續執行), 然後立即執行提供的回撥, 這個回撥允許開發者遞迴呼叫 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(), 程式碼仍然具有執行到完成的能力, 並且使得所有的變數與函式等在回撥被呼叫之前就被初始化了. 它還有個優點是可以阻止事件迴圈繼續執行. 這對於想在事件迴圈被允許繼續執行之前向用戶通知錯誤資訊是有用的. 以下是將上面的例子用 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' 事件觸發時還未被設定為回撥.

為避免這種情況, 'listening' 事件會加入一個 nextTick() 的佇列, 這樣就使得程式碼能夠執行到完成. 這樣就使得使用者可以新增他們需要的事件處理函數了.

process.nextTick() vs setImmediate()

就使用者而言, 我們有兩個相似的函式, 但它們的名字令人迷惑.

  • process.nextTick() 在同一階段立刻觸發

  • setImmediate() 在事件迴圈的下一個迭代或”tick”中觸發

實際上, 這兩個名字應該交換一下. process.nextTick() 會比 setImmediate() 更立即觸發, 這是一個歷史遺留問題, 而且不太可能去更改. 因為如果修改的話, NPM上的很大一部分包將會失效, 且每天都會有很多新的包新增到NPM, 這意味著會導致更多潛在性的包失效現象會發生. 因此雖然它們的名字令人迷惑, 這兩個名字也不會改變了.

我們建議開發者始終使用 setImmediate(), 因為它更容易推理(而且它也使程式碼能相容更多的環境, 例如瀏覽器中的JS.)

為什麼要使用 process.nextTick() ?

主要有兩點原因:

  1. 它允許使用者處理錯誤, 清除後面不再需要的資源, 或者在時間迴圈繼續執行之前重新發送請求.

  2. 有時候, 允許一個回撥函式在呼叫棧展開之後並且時間迴圈繼續執行之前執行是很有必要的.

一個例子就是要滿足使用者的期望. 程式碼如下:

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

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

listen() 在事件迴圈開始時就執行了, 但是監聽事件的回撥放在了 setImmediate() 中. 除非傳入了一個主機名, 否則會立即繫結埠. 對於事件迴圈的向前執行, 它必然會到達 poll 階段, 這也意味著可能會接收到一個連線, 並且允許連線(connection)事件在監聽(listening)事件之前被觸發.

另一個例子是執行一個建構函式, 它繼承自 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!');
});