Node.js中的事件循環

看到很多文件介紹關于Node.js中的事件循環,但是總是有些地方不是很理解,最近無意中看到了Node官方文檔中對事件循環(Event Loop)的介紹后,感覺有一種豁然開朗的感覺,但是其文檔是英文版,在此,根據個人理解,進行翻譯。
原文地址:事件循環: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/

翻譯名詞解釋

  1. Event Loop: 事件循環
  2. poll phase: 輪訓階段

翻譯內容

事件循環

事件循環通過盡可能的將相應操作分擔給系統內核,從而讓單線程的Javascript語言提供了非阻塞I/O操作。
因為目前主流的內核都是多線程的,它們可以同時在后臺執行多個操作。當其中的某個操作完成時,系統就會通知Node.js與其相關的回調函數添加到輪訓隊列(poll queue)中,最后被執行。這將在后續中詳細討論。

事件循環介紹

當Node.js運行,將會初始化一個事件循環,處理那些通過異步api調用,定時器,或者調用process.nextTick()提供的script(或者輸入到REPL中的script)。
下圖展示了事件循環的操作順序的概要。

Event Loop

注意:圖中每一個box代表事件循環的一個階段。

每個階段都會維持一個先進先出的可執行回調函數隊列。當然每個階段都有自己特殊的行為方式,即當事件循環進入一個給定的階段,它執行這個階段的任何操作,然后執行這個階段隊列中的回調函數直到隊列為空,或者回調函數調用次數達到上限。當滿足這兩個條件后,事件循環會進入下一個階段。

由于任何操作都有可能規劃更多的操作,這操作都會添加到對應階段的隊列中進行排隊(并非原文翻譯,個人解讀)。因此,需要占用大量時間運行的回調會讓poll階段運行更長的事件,設置超過定時器規定的時限。下面內容會詳細說明這種情況。

注意:在Windows和Unix/Linux實現中有輕微的差異,但是并不重要。這里講解最重要的部分。

各個階段介紹

  1. timers: 這個階段執行通過setTimeout()和setInterval()安排的回調函數。
  2. I/O callback: 這個階段執行關于close callback(如關閉socket調用的回調), 定時器安排的回調,調用setImmediate()設置的回調中產生的異常后調用的回調函數。
  3. idle: 內部使用。
  4. poll: I/O事件回調;在這個階段node會根據實際情況進行堵塞。
  5. check: 由setImmediate()設置的回調函數。
  6. close callbacks: 如socket.on('close', ...)設置的回調。

在事件循環執行過程中,Node.js檢查是否有有需要等待的異步I/O,定時器,如果沒有,結束事件循環。

各個階段詳解

timers

定時器需要指定一個時限,然而提供的回調函數的等待事件可能超過用戶期望其運行的具體時間。定時器回調函數會在到達時限后盡可能早的執行,而且系統調度或者正在運行的其他回調函數會延遲他的執行。

注意:poll階段控制timers的執行。

如:假如設置一個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
  }
});
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()完成了文件讀取,而它的加入到poll隊列中回調函數需要10ms完成執行。當回調函數執行完成后,poll隊列中已經沒有其他回調函數,所以時間循環檢查時間最近的那個定時器的時限是否已經到達,然后回到timers階段來執行定時器的回調函數。在這個例子中,你將看到從定時器設置到回調的執行總共延遲將會是105ms。

注意:為了防止poll階段耗盡事件循環,libuv(實現Node.js事件循環以及所有該平臺異步行為的C類庫)設置了精確的最大值(具體值取決于系統)用于停止輪訓更多的事件。

I/O回調函數

這個階段執行一些系統操作如TCP錯誤調用的回調函數。例如:如果TCP socket嘗試連接時,接收到ECONNREFUSED,一些*nix系統等待報告這個錯誤。這將在I/O callback階段排隊執行。

poll

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

  1. 為到達時限的定時器,執行腳本(不準確,其實是在poll隊列中輪空時,檢查定時器是否到達時限,如果到達了,則回到timers階段執行定時器回調函數)
  2. 執行poll隊列中的事件回調函數

當事件循環進入poll階段,并且此時沒有設置定時器,將會發生下面兩種情況:

  1. 如果poll隊列不是空的,事件循環將同步迭代執行隊列中的回調函數,直到poll隊列為空,或者達到執行上限。
  2. 如果poll隊列為空的,將會發生下面兩種情況:
    1)如果腳本通過setImmediate()設置,事件循環會結束poll階段,然后進入check階段來執行這些腳本。
    2)如果此時沒有通過setImmediate()設置的腳本,事件循環將停留在poll階段,等待回調函數添加到隊列中,然后立即執行。

一旦poll隊列為空,事件循環將檢查已經達到時限的定時器。如果一個或多個定時器已經準備就緒,時間循環將回到timers階段,執行這些定時器回調函數。

check

這個階段,在poll階段完成后,允許立即執行回調函數。如果poll階段閑置,且存在setImmediate()設置的隊列,事件循環將會進入check階段,而不是繼續等待。

setImmediate()實際上是一個特殊的定時器,在事件循環中占據獨立的階段。它使用libuv API設置poll階段完成后的執行回調函數。

總體上,伴隨著代碼執行完,事件循環將會進入poll階段來等待即將到達的網絡連接,請求等。然而,如果此時有使用setImediate()設置的回調函數,并且poll階段閑置,事件循環結束poll階段,進入check階段而不是等待poll事件。

close callbacks

如果一個sokect忽然關閉(如:socket.destroy()),'close'事件將會在這個階段觸發。process.nextTick()也會觸發(個人理解:這個不需要強行理解,下面有process.nextTick()具體介紹)。

setImmediate() vs setTimeout()

setImmediate()和setTimeout()兩者相似,但是調用時機不同。

  1. setImmediate()設計用來當當前poll階段完成是執行腳本。
  2. setTimeout()經過給定時間后執行腳本

這兩個定時器的執行順序非常的依賴調用上下文。如果兩個都是在主模塊中調用,定時器將會受到執行過程的約束(可能會收到機器上運行的其他應用影響)。

如,如果我們執行下面的腳本,這兩個定時器的執行順序是不確定的,因為它受到執行過程的約束。

// 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()函數主要優勢是設置在I/O回調中setImmediate()不管當前有多少定時器,總是比其他定時器先執行。

process.nextTick()

理解process.nextTick()

你可能意識到process.nextTick()并沒有出現在事件循環的圖中,即便它是異步API的一部分。這是因為process.nextTick()嚴格意義上來說,并不屬于事件循環。取而代之的是,nextTickQueue將會忽略當前事件循環的階段,在當前操作完成之后執行。

回顧事件循環結構圖,在一個特定階段的任何時間調用process.nextTick(),所有傳入process.nextTick()的回調函數將會在事件循環繼續之前執行。這可能會造成一些不良情況,因為這允許你通過迭代調用process.nextTick()耗盡I/O,從而使得事件循環不能進入poll階段。

為什么允許上述情況

為什么會允許上述情況出現在Node.js中?Node.js的設計思想是盡可能的異步,即使并不需要異步。如下代碼片段:

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

這個片段進行了參數類型的檢測,如果類型不一致,就傳遞一個err到回調函數中。其中,需要傳入回調函數中的參數,直接寫在回調函數的后面即可。

我們這里做的事情是,傳遞一個error給回調函數,但是這個回調會在用戶的剩余代碼執行完之后才會執行。通過使用process.nextTick(),我們保證apiCall()總是在剩余代碼執行之后事件循環繼續之前執行回調函數。為了實現這些,允許JS調用棧展開,然后立即執行process.nextTick()提供的回調函數。這個回調中允許迭代調用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,即使此時bar還沒有進行賦值(此時代碼還沒有運行到bar = 1;這條語句)。

通過將回到函數設置在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', () => {});

當listen()函數中,只傳遞端口號時,端口后會立馬進行設置。所以,'listening'回調函數將會立馬被調用。問題是.on('listening)回調函數將不會那個時候設置。
為了繞過這個,'listening'事件在nextTick()中排隊,從而運行腳本執行完成后在觸發。這允許開發者設置任意設置事件回調函數。

process.nextTick() vs setImmediate()

就用戶而言,這兩個函數相似,但是它們的名字很令人迷惑。

  1. process.nextTick()在同一個階段執行
  2. setImmediate()在事件循環的下一個階段或者'tick'中執行

本質上,它們的名字需要交換一下。process.nextTick()比setImmediate()更快被執行,但是這是過去的產物,很難修改。修改這個問題將會導致一大部分的npm包出現損壞。每天有更多的模塊被添加,這意味著更多潛在的損壞會出現。因此即使它們的名字令人迷惑,名字不會修改。

我們提倡開發者使用setImmediate(),因為setImmediate()具有更好的兼容性,如在瀏覽器中。

為什么使用process.nextTick()

這里有兩個主要的原因:

  1. 在事件循環繼續之前允許用戶處理錯誤,清除任何之后不需要的資源,或者可能再次請求等。
  2. 需要回調函數在調用堆棧上但是在事件循環繼續之前調用。

下面例子符合用戶的期望,如下:

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

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

listen()在第一輪事件循環執行,但是listening事件的回調函數是通過setImmediate()設置的(與前面描述不一致,但是大致可以理解為,觸發listening事件的語句放置在一個異步api,當其觸發listening事件時,此時server.on('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!');
});
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容