[NodeJs系列][譯]理解NodeJs中的Event Loop、Timers以及process.nextTick()

譯者注:

  1. 爲什麼要翻譯?其實在翻譯這篇文章前,筆者有Google了一下中文翻譯,看的不是很明白,所以纔有自己翻譯的打算,當然能力有限,文中或有錯漏,歡迎指正。
  2. 文末會有幾個小問題,大家不妨一起思考一下
  3. 歡迎關注微信公衆號:前端情報局-NodeJs系列

什麼是Event Loop?

儘管JavaScript是單線程的,通過Event Loop使得NodeJs能夠儘可能的通過卸載I/O操作到系統內核,來實現非阻塞I/O的功能。

由於大部分現代系統內核都是多線程的,因此他們可以在後臺執行多個操作。當這些操作中的某一個完成後,內核便會通知NodeJs,這樣(這個操作)指定的回調就會添加到poll隊列以便最終執行。關於這個我們會在隨後的章節中進一步說明。

Event Loop解析

當NodeJs啓動時,event loop 隨即會被初始化,而後會執行對應的輸入腳本(直接把腳本放入REPL執行不在本文討論範圍內),這個過程中(腳本的執行)可能會存在對異步API的調用,產生定時器或者調用process.nextTick(),接着開始event loop。

譯者注:這段話的意思是NodeJs優先執行同步代碼,在同步代碼的執行過程中可能會調用到異步API,當同步代碼和process.nextTick()回調執行完成後,就會開始event loop

下圖簡要的概述了event loop的操作順序:

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
注:每一個框代表event loop中的一個階段

每個階段都有一個FIFO(先進先出)的回調隊列等待執行。雖然每個階段都有其獨特之處,但總體而言,當event loop進入到指定階段後,它會執行該階段的任何操作,並執行對應的回調直到隊列中沒有可執行回調或者達到回調執行上限,而後event loop會進入下一階段。

由於任何這些階段的操作可能產生更多操作,內核也會將新的事件推入到poll階段的隊列中,所以新的poll事件被允許在處理poll事件時繼續加入隊,這也意味着長時間運行的回調可以允許poll階段運行的時間比計時器的閾值要長

注意:Windows和Unix/Linux在實現上有些差別,但這對本文並不重要。事實上存在7到8個步驟,但以上列舉的是Node.js中實際使用的。

階段概覽

  • timers:執行的是setTimeout()setInterval()的回調
  • I/O callbacks:執行除了 close callbacks、定時器回調和setImmediate()設定的回調之外的幾乎所有回調
  • idle, prepare:僅內部使用
  • poll:接收新的I/O事件,適當時node會阻塞在這裏(==什麼情況下是適當的?==)
  • checksetImmediate回調在這裏觸發
  • close callbacks:比如socket.on('close', ...)

在每次執行完event loop後,Node.js都會檢查是否還有需要等待的I/O或者定時器沒有處理,如果沒有那麼進程退出。

階段細節

timers

一個定時器會指定閥值,並在達到閥值之後執行給定的回調,但通常來說這個閥值會超過我們預期的時間。定時器回調會儘可能早的執行,不過操作系統的調度和其他回調的執行時間會造成一定的延時。

注:嚴格意義上說,定時器什麼時候執行取決於poll階段

舉個例子,假定一個定時器給定的閥值是100ms,異步讀取文件需要95ms的時間

const fs = require('fs');

function someAsyncOperation(callback) {
  // 假定這裏花費了95ms
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(function() {

  const delay = Date.now() - timeoutScheduled;

  console.log(delay + 'ms have passed since I was scheduled');
}, 100);


// 95ms後異步操作才完成
someAsyncOperation(function() {

  const startCallback = Date.now();

  // 這裏花費了10ms
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

就本例而言,當event loop到達poll階段,它的隊列是空的(fs.readFile()還未完成),因此它會停留在這裏直到達到最早的定時器閥值。fs.readFile()
花費了95ms讀取文件,之後它的回調被推入poll隊列並執行(執行花了10ms)。回調執行完畢後,隊列中已經沒有其他回調需要執行了,那麼event loop就會去檢查是否有定時器的回調可以執行,如果有就跳回到timer階段執行相應回調。在本例中,你可以看到從定時器被調用到其回調被執行一共耗時105ms。

注:爲了防止event loop一直阻塞在poll階段,libuv(http://libuv.org/ 這是用c語言實現了Node.js event loop以及各個平臺的異步行爲的庫)會指定一個硬性的最大值以阻止更多的事件被推入poll。

I/O callbacks階段

這個階段用於執行一些系統操作的回調,比如TCP錯誤。舉個例子,當一個TCP socket 在嘗試連接時接收到ECONNREFUSED的錯誤,一些*nix系統會想要得到這些錯誤的報告,而這都會被推到 I/O callbacks中執行。

poll階段

poll階段有兩個功能:

  1. 執行已經達到閥值的定時器腳本
  2. 處理在poll隊列中的事件

當event loop進入到poll階段且此代碼中爲設定定時器,將會發生下面情況:

  1. 如果poll隊列非空,event loop會遍歷執行隊列中的回調函數直到隊列爲空或達到系統上限
  2. 如果poll隊列是空的,將會發生下面情況:

    • 如果腳本中存在對setImmediate()的調用,event loop將會結束poll階段進入check階段並執行這些已被調度的代碼
    • 如果腳本中不存在對setImmediate()的調用,那麼event loop將阻塞在這裏直到有回調被添加進來,新加的回調將會被立即執行

一旦poll隊列爲空,event loop就會檢查是否有定時器達到閥值,如果有1個或多個定時器符合要求,event loop將將會回到timers階段並執行改階段的回調.

check階段

一旦poll階段完成,本階段的回調將被立即執行。如果poll階段處於空閒狀態並且腳本中有執行了setImmediate(),那麼event loop會跳過poll階段的等待進入本階段。

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

通常而言,隨着代碼的執行,event loop最終會進入poll階段並在這裏等待新事件的到來(例如新的連接和請求等等)。但是,如果存在setImmediate()的回調並且poll階段是空閒的,那麼event loop就會停止在poll階段漫無目的的等等直接進入check階段。

close callbacks階段

如果一個socket或者handle突然關閉(比如:socket.destory()),close事件就會被提交到這個階段。否則它將會通過process.nextTick()觸發

setImmediate() 和 setTimeout()

setImmediatesetTimeout()看起來是比較相似,但它們有不同的行爲,這取決於它們什麼時候被調用。

  • setImmediate() 被設計成一旦完成poll階段就會被立即調用
  • setTimeout() 則是在達到最小閥值是纔會被觸發執行

其二者的調用順序取決於它們的執行上下文。如果兩者都在主模塊被調用,那麼其回調被執行的時間點就取決於處理過程的性能(這可能被運行在同一臺機器上的其他應用影響)

比如說,如果下列腳本不是在I/O循環中運行,這兩種定時器運行的順序是不一定的(==這是爲什麼?==),這取決於處理過程的性能:

// timeout_vs_immediate.js
setTimeout(function timeout() {
  console.log('timeout');
}, 0);

setImmediate(function immediate() {
  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

使用setImmediate()而不是setTimeout()的主要好處是:如果代碼是在I/O循環中調用,那麼setImmediate()總是優先於其他定時器(無論有多少定時器存在)

process.nextTick()

理解process.nextTick()

你可能已經注意到process.nextTick()不在上面的圖表中,即使它也是異步api。這是因爲嚴格意義上來說process.nextTick()不屬於event loop中的一部分,它會忽略event loop當前正在執行的階段,而直接處理nextTickQueue中的內容。

回過頭看一下圖表,你在任何給定階段調用process.nextTick(),在繼續event loop之前,所有傳入process.nextTick()的回調都會被執行。這可能會導致一些不好的情況,因爲它允許你遞歸調用process.nextTick()從而使得event loop無法進入poll階段,導致無法接收到新的 I/O事件

爲什麼這會被允許?

那爲什麼像這樣的東西會被囊括在Node.js?部分由於Node.js的設計理念:API應該始終是異步的即使有些地方是沒必要的。舉個例子:

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

這是一段用於參數校驗的代碼,如果參數不正確就會把錯誤信息傳遞到回調。最近process.nextTick()有進行一些更新,使得我們可以傳遞多個參數到回調中而不用嵌套多個函數。

我們(在這個例子)所做的是在保證了其餘(同步)代碼的執行完成後把錯誤傳遞給用戶。通過使用process.nextTick()我們可以確保apiCall()的回調總是在其他(同步)代碼運行完成後event loop開始前調用的。爲了實現這一點,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()(函數名可以看出),但實際上操作是同步的。當它被調用時,其回調也在event loop中的同一階段被調用了,因爲someAsyncApiCall()實際上並沒有任何異步動作。結果,在(同步)代碼還沒有全部執行的時候,回調就嘗試去訪問變量bar

通過把回調置於process.nextTick(),腳本就能完整運行(同步代碼全部執行完畢),這就使得變量、函數等可以先於回調執行。同時它也有阻止event loop繼續執行的好處。有時候我們可能希望在event loop繼續執行前拋出一個錯誤,這種情況下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', () => {});

當只有一個端口作爲參數傳入,端口會被立即綁定。所以監聽回調可能被立即調用。問題是:on('listening') 回調在那時還沒被註冊。

爲了解決這個問題,把listening事件加入到nextTick() 隊列中以允許腳本先執行完(同步代碼)。這允許用戶(在同步代碼中)設置任何他們需要的事件處理函數。

process.nextTick() 和 setImmediate()

對於用戶而言,這兩種叫法是很相似的但它們的名字又讓人琢磨不透。

  • process.nextTick() 會在同一個階段執行
  • setImmediate() 會在隨後的迭代中執行

本質上,這兩個的名字應該互換一下,process.nextTick()setImmediate()更接近於立即,但是由於歷史原因這不太可能去改變。名字互換可能影響大部分的npm包,每天都有大量的包在提交,這意味這越到後面,互換造成的破壞越大。所以即使它們的名字讓人困惑也不可能被改變。

我們建議開發者在所有情況中使用setImmediate(),因爲這可以讓你的代碼兼容更多的環境比如瀏覽器。

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

這裏又兩個主要的原因:

  1. 讓開發者處理錯誤、清除無用的資源或者在event loop繼續之前再次嘗試重新請求資源
  2. 有時需要允許回調在調用棧展開之後但在事件循環繼續之前運行

下面這個例子會滿足我們的期望:

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

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

假設listen()是在event loop開始前運行,但是監聽回調是包裹在setImmediate中,除非指定hostname參數否則端口將被立即綁定(listening回調被觸發),event loop必須要執行到poll階段纔會去處理,這意味着存在一種可能:在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', function() {
  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(function() {
    this.emit('event');
  }.bind(this));
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', function() {
  console.log('an event occurred!');
});

譯者注(Q&A)

翻譯完本文,筆者給自己提了幾個問題?

  1. poll階段什麼時候會被阻塞?
  2. 爲什麼在非I/O循環中,setTimeoutsetImmediate的執行順序是不一定的?
  3. JS調用棧展開是什麼意思?
  4. 爲什麼process.nextTick()可以被遞歸調用?

筆者將在之後的文章[《Q&A之理解NodeJs中的Event Loop、Timers以及process.nextTick()》]()探討這些問題,有興趣的同學可以關注筆者的公衆號: 前端情報局-NodeJs系列獲取最新情報

原文地址: https://github.com/nodejs/nod...

image

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章