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

原文連接html

什麼是事件循環

雖然js是單線程的,可是事件循環會盡量地將卸載操做(offloading operations)託付給系統內核,讓node可以執行非阻塞的I/O操做node

因爲大多數現代內核都是多線程的,所以它們能夠處理在後臺執行的多個操做。當其中任意一個任務完成後,內核都會通知Node.js,以保證將相對應的回調函數推入poll隊列中最終執行。稍後咱們將在本文中詳細解釋這一點。npm

事件循環的定義

當Node.js服務啓動時,它就會初始化事件循環。每當處理到腳本(或者是放置到REPL執行的代碼,本文咱不說起)中異步的API, 定時器,或者調用process.nextTick()都會觸發事件循環,api

下圖簡單描述了事件循環的執行順序瀏覽器

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

注: 每一個方框都是事件循環的一個階段多線程

每一個階段都有一個待執行回調函數的FIFO隊列, 雖然每一個階段都不盡相同,整體上說,當事件循環到當前階段時,它將執行特定於該階段的操做,而後就會執行被壓入當前隊列中的回調函數, 直到隊列被清空或者達到最大的調用上限。 當隊列被清空或者達到最大的調用上限時,事件循環就會進入到下一階段,如此反覆。異步

由於任意階段的操做都有可能調用更多的任務和觸發新的事件,這些事件都最終會由內核推入poll階段,poll事件能夠在執行事件的時候插入隊列。因此調用棧很深的回調容許poll階段運行時間比定時器的閥值更久,詳細部分請查看定時器和poll部分的內容。socket

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

階段概覽

  • 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事件或者定時器,若是沒有就會乾淨得關掉它。ide

階段的細節

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

複製代碼
相關文章
相關標籤/搜索