Node.js 指南(Node.js事件循環、定時器和process.nextTick())

Node.js事件循環、定時器和process.nextTick()

什麼是事件循環?

事件循環容許Node.js執行非阻塞I/O操做 — 儘管JavaScript是單線程的 — 經過儘量將操做卸載到系統內核。node

因爲大多數現代內核都是多線程的,所以它們能夠處理在後臺執行的多個操做,當其中一個操做完成時,內核會告訴Node.js,以即可以將相應的回調添加到輪詢隊列中以最終執行,咱們將在本主題後面進一步詳細解釋。npm

事件循環解釋

當Node.js啓動時,它初始化事件循環,處理提供的可能會進行異步API調用、調度定時器或調用process.nextTick()的輸入腳本(或放入REPL,本文檔未涉及),而後開始處理事件循環。segmentfault

下面的圖解顯示了事件循環操做順序的簡要概述。api

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

注意:每一個框都將被稱爲事件循環的「階段」。瀏覽器

每一個階段都有一個要執行的回調FIFO隊列,雖然每一個階段都以其本身的方式特殊,但一般狀況下,當事件循環進入給定階段時,它將執行特定於該階段的任何操做,而後在該階段的隊列中執行回調,直到隊列耗盡或已執行最大回調數。當隊列耗盡或達到回調限制時,事件循環將移至下一階段,依此類推。多線程

因爲任何這些操做均可以調度更多操做,而且在輪詢階段處理的新事件由內核排隊,輪詢事件能夠在處理輪詢事件時排隊,所以,長時間運行的回調能夠容許輪詢階段的運行時間遠遠超過定時器的閾值,有關詳細信息,請參閱timerspoll部分。異步

注意:Windows和Unix/Linux實現之間存在輕微差別,但這對於此示範並不重要,最重要的部分在這裏,實際上有七到八個步驟,但咱們關心的是 — Node.js實際使用的那些 — 是上面那些。socket

階段概述

  • timers:此階段執行由setTimeout()setInterval()調度的回調。
  • pending callbacks:執行延遲到下一個循環迭代的I/O回調。
  • idle, prepare:僅在內部使用。
  • poll:檢索新的I/O事件;執行與I/O相關的回調(幾乎全部,除了close callbacks、由定時器調度的一些和setImmediate());node將在適當的時候在這裏阻塞。
  • check:這裏調用setImmediate()回調函數。
  • close callbacks:一些關閉回調,例如socket.on('close', ...)

在事件循環的每次運行之間,Node.js檢查它是否在等待任何異步I/O或定時器,若是沒有,則完全關閉。async

階段的細節

timers

定時器指定閾值,在該閾值以後能夠執行提供的回調而不是人們但願它執行的確切時間,定時器回調將在指定的時間事後能夠調度,可是,操做系統調度或其餘回調的運行可能會延遲它們。函數

注意:從技術上講,輪詢階段控制什麼時候執行定時器。

例如,假設你在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()還沒有完成),因此它將等待剩餘的ms數,直到達到最快的定時器閾值,當它等待95毫秒經過,fs.readFile()完成了讀取文件,其須要10毫秒完成的回調被添加到輪詢隊列並執行,當回調結束時,隊列中再也不有回調,所以事件循環將看到已達到最快定時器的閾值而後回到定時器階段以執行定時器的回調,在此示例中,你將看到正在調度的定時器與正在執行的回調之間的總延遲將爲105毫秒。

注意:爲了防止輪詢階段耗盡事件循環,libuv(實現Node.js事件循環的C庫以及平臺的全部異步行爲)在中止輪詢更多事件以前,還具備硬性最大值(取決於系統)。

pending callbacks

此階段執行某些系統操做(例如TCP錯誤類型)的回調,例如,若是TCP socket在嘗試鏈接時收到ECONNREFUSED,某些*nix系統要等待報告錯誤,這將在等待回調階段排隊執行。

poll

輪詢階段有兩個主要功能:

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

當事件循環進入輪詢階段而且沒有定時器被調度時,將發生如下兩種狀況之一:

  • 若是輪詢隊列不爲空,則事件循環將遍歷其同步執行它們的回調隊列,直到隊列已用盡,或者達到系統相關的硬限制。
  • 若是輪詢隊列爲空,則會發生如下兩種狀況之一:

    • 若是setImmediate()已調度腳本,則事件循環將結束輪詢階段並繼續執行檢查階段以執行這些調度腳本。
    • 若是setImmediate()還沒有調度腳本,則事件循環將等待將回調添加到隊列,而後當即執行它們。

輪詢隊列爲空後,事件循環將檢查已達到時間閾值的定時器,若是一個或多個定時器準備就緒,事件循環將回繞到定時器階段以執行那些定時器的回調。

check

此階段容許人員在輪詢階段完成後當即執行回調,若是輪詢階段變爲空閒而且腳本已使用setImmediate()排隊,則事件循環能夠繼續到檢查階段而不是等待。

setImmediate()其實是一個特殊的定時器,它在事件循環的一個單獨階段運行,它使用libuv API來調度在輪詢階段完成後執行回調。

一般,在執行代碼時,事件循環最終將進入輪詢階段,在此階段它將等待傳入鏈接、請求等,可是,若是已使用setImmediate()調度回調而且輪詢階段變爲空閒,則它將結束並繼續到檢查階段,而不是等待輪詢事件。

close callbacks

若是socket或handle忽然關閉(例如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週期內,則始終首先執行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()的全部回調都將在事件循環繼續以前獲得解決,這可能會產生一些糟糕的狀況,由於它容許你經過進行遞歸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 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() vs setImmediate()

就用戶而言,咱們有兩個相似的調用,但它們的名稱使人困惑。

  • process.nextTick()在同一階段當即觸發。
  • setImmediate()在事件循環的後續迭代或'tick'觸發。

實質上,應該交換名稱,process.nextTick()setImmediate()更快地觸發,但這是過去的一個工件,不太可能改變。進行此切換會破壞npm上的大部分包,天天都會添加更多新模塊,這意味着咱們天天都在等待更多潛在的破損,雖然它們使人困惑,但名稱自己不會改變。

咱們建議開發人員在全部狀況下都使用setImmediate(),由於它更容易推理(而且它使代碼與更普遍的環境兼容,如瀏覽器JS)。

爲何要使用process.nextTick()

主要有兩個緣由:

  1. 容許用戶處理錯誤、清除任何不須要的資源,或者在事件循環繼續以前再次嘗試請求。
  2. 有時,在調用堆棧已解除但在事件循環繼續以前,必須容許回調運行。

一個例子是匹配用戶的指望,簡單的例子:

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

你沒法當即從構造函數中發出事件,由於腳本尚未處理到用戶爲該事件分配回調的位置,所以,在構造函數自己中,你可使用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!');
});

上一篇:阻塞與非阻塞概述

下一篇:不要阻塞事件循環(或工做池)

相關文章
相關標籤/搜索