Node.js 中的事件循環計時器和process.nextTick()

前言

本篇文章翻譯自 Node.js 官網的同名文章也算是經典老物了, 不過官網的文章也隨着 Node.js 的演化在修改, 這篇文章最後的編輯時間是 2019年9月10日請注意時效性, 地址在文章的最後有給出.javascript

首次翻譯英語水平有限, 錯誤之處還請多多指教.html

什麼是事件循環

事件循環容許node.js執行非阻塞I/O操做. 雖然 JavaScript 是單線程的, 可是事件循環會盡量的將操做轉移到系統內核中來完成.java

現代的操做系統內核都是多線程的, 它們能夠在後臺處理多種操做. 一旦這些操做完成, 系統內核會通知 Node.js 以便將事件回調放入輪詢隊列中等待執行. (咱們會在隨後的內容討論它們的具體工做細節)node

解析事件循環

當 Node.js 啓動的時候, 他會初始化事件循環, 處理輸入的腳本內容 (或者進入 REPL), 腳本可能會調用異步接口, 設置定時器, 或者調用 process.nextTick(), 而後開始處理事件循環(eventloop).npm

下面的簡圖中展現了事件循環的操做流程:segmentfault

┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
每個方框表明了事件循環中不一樣的階段(全部階段執行完成算是一次事件循環).

每個階段都有一個由回調組成的 FIFO 隊列被用於執行. 雖然不一樣的隊列執行方式不一樣, 總的來看, 當事件循環進入該階段後會執行該階段對應的操做, 而後調用對應的回調直到隊列耗盡或者達到了回調執行上限. 在到達上述狀況後事件循環進入下一階段, 而後繼續這樣的流程.api

因爲處理單個操做可能會產生新的操做以及在輪詢階段產生的新事件會被內核排隊, 在輪詢事件(poll events)的過程當中輪詢事件會被排隊. 所以, 執行一個長耗時的回調會超出在輪詢階段設定的定時器的閾值.瀏覽器

Windows and the Unix/Linux 平臺略有差異, 可是這不影響咱們的討論. 咱們最關心的是 Node.js 實際執行的那部分也就是上面的內容.

階段總覽

  • timer: 此階段執行由 setTimeout()setInterval() 設定的回調.
  • pending callbacks: 執行被推遲到下一輪循環的 I/O 回調.
  • idle, prepare: 僅內部使用.
  • poll: 獲取新的I/O事件; 執行 I/O 回調(除了 close 回調以及 timer 回調和 setImmediate 回調都會在這裏執行), node會在適當條件下在這裏阻塞.
  • check: setImmediate 回調將會在次執行.
  • close callbacks: 一些執行關閉的函數, 例如 socket.on('close', ...).

Node 會在兩次完整的事件循環間檢查是否存在 I/O 操做和或者 timer, 若是沒有就會退出執行.bash

各階段中的細節

timer

timer(計時器) 指定了執行給定回調的閾值時間, 而不是人們所想的準確執行時間. 定時器回調將會在指定的時間到達後儘快的執行, 不過 timer 的執行會受到操做系統調度和其餘回調執行的影響被延後.多線程

從技術上講, 決定是否執行 timer 回調是在輪詢階段控制的, 在 timer 階段纔會執行這些回調.

舉例來講, 你制定了一個延時 100ms 的 timer, 而後異步進行讀取文件花費了 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
  }
});

當事件循環進入輪詢隊列後, 此時隊列是空的(fs.readFile() 還未完成), 如今咱們等待計時器到達指定的閾值. 過了 95ms 後 fs.readFile 讀取完畢而且執行回調共花費 10ms. 當回調執行完成, 輪詢隊列中沒有任何內容了, 此時事件循環會看到已經到達閾值的 timer, 而後在 timer階段去執行回調. 因此在這個例子中的延時函數會在 105ms 後執行.

爲了防止事件循環被長時間空置, libuv 有一個最大限值(取決於操做系統)用於限制輪詢隊列的執行次數.

pending callbacks

系統操做例如: TCP類型錯誤執行回調會安排在這個階段執行. 例如當嘗試 TCP 鏈接的時候接收到了一個 ECONNREFUSED 錯誤, 有些 *nix 系統會進行等待而不是當即拋出錯誤. 這些回調會被添加到隊列中在 pending callbacks 階段執行.

poll

事件輪詢階段主要有兩大功能:

  1. 計算須要阻塞多長時間, 而且進行I/O輪詢, 而後
  2. 處理輪詢隊列中的事件

當事件輪詢到了 poll 階段的時候發現沒有計時器到達閾值, 此時會發生兩種狀況:

  1. 若是輪詢隊列中有內容, 事件循環會遍歷輪詢隊列而後同步調用其中的回掉, 直到隊列清空或接近輪詢階段的回調執行上限(上限取決於操做系統).
  2. 若是輪詢隊列爲空, 此時

    • 若是存在 setImmediate() 任務, 事件循環會結束輪詢階段直接跳入 check 階段去執行那些 setImmediate() 任務.
    • 若是沒有須要處理的 setImmediate() 任務, 事件循環會在輪詢階段等待新的任務被添加到輪詢隊列中, 而後當即處理這些添加進來的任務.

輪詢隊列爲空後, 事件循環將檢查已達到時間閾值的計時器. 若是有計時器到達閾值, 事件循環會移動到 timer 階段而後執行那些計時器回調.

check

這個階段容許在輪詢階段完成後執行回調. 若是輪詢階段進入等待, 而且有被 setImmediate() 設定的回調, 那麼事件循環有可能會移動到 check 階段而不是繼續在輪詢階段等待.

setImmediate() 其實是一個特殊的計時器, 在事件循環的一個單獨階段中執行. 它經過 libuv API 在輪詢階段結束後執行由 setImmediate() 設定的回調.

一般來講, 隨着代碼的運行事件循環終將進入事件輪詢階段並在此等待鏈接的傳入或者請求等. 可是若是存在使用 setImmediate() 設定的任務且時間輪詢進入了等待(idle 階段), 事件循環會進入到 check 階段而不是繼續等待下去.

close

若是 socket 或者 handle 忽然的關閉, 它們的 close 事件會在這個階段執行. 不然它會經由 process.nextTick() 執行.

setImmediate() vs setTimeout()

setImmediate()setTimeout() 很像, 但根據調用時機的差別它們的行爲方式有所區別.

  • setImmediate() 被設計在當前的事件輪詢階段(poll phase)結束後執行腳本一次.
  • 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

譯者說明: 在腳本執行首次執行完成後, setImmediatesetTimeout 被添加到了事件循環中. 在第二輪事件循環中若是進程性能通常已經到達 timer 的閾值了就會在 timer 階段執行定時器任務, 隨後執行 setImmediate 設定的任務. 若是線程性能足夠就會由於不夠計時器閾值跳過 timer 階段去執行 setImmediate 設定的任務.

可是若是你將這兩個計時器移動到 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

譯者說明: 文件操做是 I/O操做實在 poll 階段執行的, 回調執行完成後 poll 隊列是空着的, 此時 timer 已經在 poll 階段被設定完成(timer 階段執行), 此時存在 setImmediate 任務因此直接進入到了 check 階段.

使用 setImmediate 的優勢是始終在定時器前執行(在 I/O循環中), 而無論設置了多少個定時器.

process.nextTick()

你可能注意到了 process.nextTick() 沒有出如今以前的圖中, 雖然它是異步 API 的組成部分. 從技術角度來看 process.nextTick() 並非事件循環的一部分. nextTickQueue 老是在當前操做執行完成後執行

水平有限, 有關 "操做" 的定義在原文以下:

Here, an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed.

回到剛纔的流程圖上, 你能夠在圖上的任意階段執行 process.nextTick(), 全部經過 process.nextTick() 註冊的回調都會在事件循環進入到下一個階段前處理. 這種設計會形成一些很差的狀況, 若是你遞歸調用 process.nextTick() 他會 "餓死" I/O, 由於這會阻止事件循環進入到事件輪詢階段.

爲何容許這樣的設計?

爲何這樣的設計被包含到了 Node.js 中?這是 Node.js 設計理念的一部分, 接口永遠應該是異步的即便是它同步也沒有問題, 舉例來講:

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

這段代碼會對參數進行檢查當類型錯誤會拋出 error. process.nextTick() 在最近的更新中容許傳入參數, 而後將參數傳入到回調中而沒必要嵌套一個函數來包裝實現相似的功能.

上段代碼中咱們會向用戶通知錯誤, 可是隻有用戶的代碼執行完成後這個錯誤纔會被執行. 藉助於 process.nextTick() 咱們能夠確保 apiCall() 調用的 callback 永遠在當前用戶代碼執行完成以後以及在事件循環進入下一階段前執行代碼. 爲了達到這一點, JS 調用棧容許展開後當即執行那些給定的回調, 這樣作容許用戶經過 process.nextTick 建立遞歸的代碼可是不會形成 V8 引擎的棧溢出錯誤 RangeError: Maximum call stack size exceeded from v8.

水平有限, 原文以下:

To achieve this, the JS call stack is allowed to unwind then immediately execute the provided callback which allows a person to make recursive calls to process.nextTick() without reaching a RangeError: Maximum call stack size exceeded from v8.

這種理念可能會致使問題出現, 舉例來講:

let bar;

// 這是擁有異步接口設計的函數, 在其內部確實同步的
function someAsyncApiCall(callback) { callback(); }

// 內部的回調會在 someAsyncApiCall 執行完成前調用
someAsyncApiCall(() => {
  // 因爲 someAsyncApiCall 當即執行, 此時的 bar 還未被指定值
  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') 回掉還未被註冊.

經過使用 nextTick() 來將內部的 listening 事件排隊, 讓腳本有機會去執行完成. 這纔可讓用戶去註冊它們想要的監聽器.

process.nextTick() vs setImmediate()

對於使用者來講這兩個接口的功能是相似的, 可是它們的名稱卻使人難以琢磨.

  • process.nextTick() 在事件循環的某個階段中所有執行
  • setImmediate() 在事件循環的隨後的迭代中觸發

原文:

  • process.nextTick() fires immediately on the same phase
  • setImmediate() fires on the following iteration or 'tick' of the event loop

從本質上看, 它們應該交換名稱. process.nextTick() 從調用到出發所花費的時間比 setImmediate() 還要短, 可是這個坑已經被埋了過久了很難再被修復了. 若是要是修改命名會讓 npm 上的大部分包掛掉. 隨着 npm 上的包愈來愈多嘗試修復的代價也愈來愈高. 雖然命名有問題, 可是也沒法修改了.

咱們開發者在全部的狀況下都使用 setImmediate 由於它更加容易推理(也可讓代碼更具兼容性, 好比在瀏覽器中運行).

爲何使用process.nextTick()?

主要緣由有兩個:

  1. 運行用戶處理錯誤, 清理不須要的資源, 或者在事件循環進入下一階段前嘗試再次發送請求.
  2. 有時須要回掉在棧展開(unwind)後可是事件循環還未進入到下一階段前執行.

有一個符合用戶預期的例子:

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

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

假設事件循環中第一個運行的是 listen(), 可是用於監聽的回調是使用 setImmediate 設置的. 除非主機名稱已經被傳入, 不然將當即綁定到端口. 要使事件循環繼續, 它必須進入到輪詢階段. 這意味着在 listening 前創建的鏈接會在 listening 事件觸發前執行 connection 事件.

另外一個例子是運行一個函數構造函數, 繼承自 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!');
});

參考

The Node.js Event Loop, Timers, and process.nextTick()

由setTimeout和setImmediate執行順序的隨機性窺探Node的事件循環機制

Node探祕之事件循環(2)--setTimeout/setImmediate/process.nextTick的差異

Node 定時器詳解

nodejs的eventloop,timers和process.nextTick()【譯】

相關文章
相關標籤/搜索