1. 程式人生 > >【nodejs原理&原始碼賞析(7)】【譯】Node.js中的事件迴圈,定時器和process.nextTick

【nodejs原理&原始碼賞析(7)】【譯】Node.js中的事件迴圈,定時器和process.nextTick

目錄

  • Event Loop 是什麼?
  • Event Loop 基本解釋
  • 事件迴圈階段概覽
  • 事件迴圈細節
    • timers
    • pending callbacks
    • poll階段
    • check
    • close callbacks
  •  setImmediate( )和setTimeout( )
  • proess.nextTick( )
    • 理解 process.nextTick()
    • 為什麼會允許這種情況存在?
    • process.nextTick( )對比setImmediate( )
    • 為什麼使用process.nextTick()

示例程式碼託管在:http://www.github.com/dashnowords/blogs

部落格園地址:《大史住在大前端》原創博文目錄

華為雲社群地址:【你要的前端打怪升級指南】

原文地址:https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick

如果你常年遊走於Nodejs中文網,可能已經錯過了官方網站上的第一手資料,Nodejs中文網並沒有翻譯這些非常高質量的核心文章,只提供了中文版的API文件(已經很不容易了,沒有任何黑它的意思,我也是中文網的受益者),它們涵蓋了Node.js中從核心概念到相關工具等等非常重要的知識,下面是博文的目錄,你知道該怎麼做了。

Event Loop 是什麼?

事件迴圈是Node.js能夠實現非阻塞I/O的基礎,儘管JavaScript應用是單執行緒執行的,但是它可以將操作向下傳遞到系統核心去執行。

大多數現代系統核心都是支援多執行緒的,它們可以同時在後臺處理多個操作。當其中任何一個任務完成後,核心會通知Node.js,這樣它就可以把對應的回撥函式新增進poll佇列,回撥函式最終就能夠被執行,後文中我們還會進行更詳細的解釋。

Event Loop 基本解釋

Node.js開始執行時,它就會初始化Event Loop,然後處理指令碼檔案(或者在REPL(read-eval-print-loop)環境中執行,本文不做深入探討)中的非同步API呼叫,定時器,或process.nextTick

方法呼叫,然後就會開始處理事件迴圈(Event Loop)。

下圖展示了事件迴圈的各個階段(每一個盒子被稱為事件迴圈中一個“階段”):

每一個階段都維護了一個先進先出的待執行回撥函式佇列,儘管每一個階段都有自己獨特的處理方式,但總體來說,當事件迴圈進入一個具體的階段時,它將處理與這個階段有關的所有操作,然後執行這個階段對應佇列中的回撥函式直到佇列為空,或者達到了該階段允許執行函式的數量的最大值,當滿足任何一個條件時,事件迴圈都會進入下一個階段,以此類推。

因為任何階段相關的操作都可能導致更多的待執行操作產生,而新事件會被核心新增進poll佇列中,當poll佇列中的回撥函式被執行時允許繼續向當前階段的poll佇列中新增新的回撥函式,於是長時間執行的回撥函式可能就會導致事件迴圈在poll階段停留時間過長,你可以在後文的timerspoll章節檢視更多的內容。

提示:Windows和Unix/Linux在實現上有細小的差別,但並不影響本文的演示,不同的系統可能會存在7-8個階段,但是最終要的階段上圖中已經展示了,這些是Node.js實際會使用到的。

事件迴圈階段概覽

  • timers-本階段執行通過setTimeout( )setInterval( )新增的已經到時的計劃任務
  • pending callbacks-將一些I/O回撥函式延遲到下一迴圈執行(這裡不是很確定)
  • idle,prepare-內部使用的階段
  • poll-檢查新的I/O事件;執行相關I/O的回撥(除了“close回撥”,“定時器回撥”和setImmediate( )新增的回撥外幾乎所有其他回撥函式);node有可能會在這裡產生阻塞
  • check-執行setImmediate( )新增的回撥函式
  • close callbacks-用於關閉功能的回撥函式,例如socket.on('close',......)

在每輪事件週期之間,Node.js會檢查是否有處於等待中的非同步I/O或定時器,如果沒有的話就會關閉當前程式。

事件迴圈細節

timers

一個timer會明確一個時間點,回撥函式會在時間超過這個時間點後被執行,而不是開發者希望的精確時間。一旦定時器時間過期,回撥函式就會盡可能早地被排程執行,然而作業系統的排程方式和其他的回撥函式都有可能會導致某個定時器回撥函式被延遲。

提示:技術上來說,poll階段控制著timers如何被執行。

下面的示例中,你使用了一個100ms後過期的定時器,接著花費了95ms使用非同步檔案讀取API非同步讀取了某個檔案:

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的執行佇列是它被執行。當回撥函式執行完畢後,佇列中沒有更多的回撥函數了,事件迴圈就會再次檢查下一個待觸發的timer是否已經到期,如果是,則事件迴圈就會繞回timers階段去執行到期timer的回撥函式。在這個示例中,你會看到timer從設定定時器到回撥函式被觸發一共花費了105ms.

注意:為了避免在poll階段阻塞事件迴圈,libuv(Node.js底層用於實現事件迴圈和非同步特性的C語言庫)設定了一個硬上限值(該值會根據系統不同而有變化),使得poll階段只能將有限數量的回撥函式新增進poll佇列。

pending callbacks

這個階段會執行一些系統操作的回撥函式,例如一些TCP的錯誤。比如一個TCP的socket物件嘗試連線另一個socket時收到了ECONNREFUSED,一些Linux系統會希望彙報這類錯誤,這類回撥函式就會被新增在pending callbacks階段的待執行佇列中。

poll階段

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

  1. 計算需要阻塞的時長,以便可以將完成的I/O新增進待執行佇列
  2. 執行poll佇列中產生的事件

當事件迴圈進入poll階段且此時並沒有待執行的timer時,會按照下述邏輯來判斷:

  • 如果poll佇列不為空,事件迴圈會以同步的方式逐個迭代執行佇列中的回撥函式直到佇列耗盡,或到達系統設定的處理事件數量限制。
  • 如果poll佇列為空,則按照下述邏輯繼續判斷:
    • 如果指令碼中使用setImmediate( )方法添加了回撥函式,事件迴圈就會結束poll階段,並進入check階段來執行這些新增的回撥函式。
    • 如果沒有使用setimmediate( )新增的回撥,事件迴圈就會等待其他回撥函式被新增進佇列並立即執行新增的函式。

一旦poll佇列為空,事件迴圈就會檢查是否有已經到期的timers定時器,如果有一個或多個定時器到期,事件迴圈就會回到timers階段來執行這些定時器的回撥函式。

check

這個階段允許開發者在poll階段結束後立即執行一些回撥函式。如果poll階段出現閒置或者指令碼中使用setImmediate( )添加了回撥函式,事件迴圈事件迴圈就會主動進入check階段而不會停下來等待。

setImmediate( )實際上是一個執行在獨立階段的特殊定時器。它通過呼叫libuv提供的API新增那些希望在poll階段完成以後執行的回撥函式。

通常,隨著程式碼的執行,事件迴圈最終會到達poll階段,它會在這裡等待incoming connection,request等請求事件。然而,如果一個回撥函式被setImmediate( )新增時poll階段處於空閒狀態,它就會結束並進入check階段而不是繼續等待poll事件。

close callbacks

如果一個socket或者控制代碼被突然關閉(比如呼叫socket.destroy( )),close事件就會在這個階段被髮出。否則(其他形式觸發的關閉)事件將會通過process.nextTick( )來發送。

 setImmediate( )和setTimeout( )

setImmediate( )setTimeout( )非常相似,但是表現卻不相同。

  • setImmediate( )被設計來在當前poll階段完成後執行一些指令碼
  • setTimeout( )會把一個指令碼新增為一定時間過去後才執行的“待執行任務”

這兩種定時器被執行的順序依賴於呼叫定時器的上下文。如果都是在主模組中呼叫,定時器就會與process的效能相關(這也意味著它可能被同一個機器上的其他應用影響)。

例如下面的指令碼中,如果我們一個不包含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( )的主要優勢在於在I/O回撥函式中呼叫時,不論程式中有多少timers,它新增的回撥函式總是比其他timers更早執行。

proess.nextTick( )

理解 process.nextTick()

你可能已經注意到儘管同樣作為非同步API的一部分,process.nextTick( )並沒有展示在上面的圖表中,因為技術層面來講它並不是事件迴圈中的一部分。nextTickQueue佇列將會在當前操作執行完後立即執行,無論當前處於事件迴圈的哪個階段,這裡所說的操作是指底層的C/C++控制代碼到待執行JavaScript程式碼的過渡(這句怪怪的,不知道怎麼翻譯,原文是 an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed)。

再來看上面的圖表,任何時候當你在某個階段呼叫process.nextTick( ),所有傳入的回撥函式都會在event loop繼續之前先被解析執行。這可能會造成非常嚴重的影響,因為它允許你阻塞通過遞迴呼叫process.nextTick( )而使得事件迴圈產生阻塞,是它無法到達poll階段。

為什麼會允許這種情況存在?

為什麼這種匪夷所思的情況要被包含在Node.js中呢?一部分是由於Node.js的設計哲學決定的,Node.js中認為API無論是否有必要,都應該非同步執行,例如下面的程式碼示例片段:

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

這個示例對引數進行了檢查,如果引數型別是錯誤的,它就會將這個錯誤傳遞給回撥函式。這個API允許process.nextTick獲取新增在callback之後的其他引數,並支援以冒泡的方式將其作為callback呼叫時傳入的引數,這樣你就不必通過函式巢狀來實現了。

這裡我們做的事情是允許剩餘的程式碼執行完畢後再傳遞一個錯誤給使用者。通過使用process.nextTick( )就可以確保apiCall( )方法總是在剩餘的程式碼執行完和事件迴圈繼續進行這兩個時間點之間來執行回撥函式。為了達到這個目的,JS調動棧就會允許立刻執行一些回撥函式並允許使用者在其中遞迴觸發呼叫process.nextTick( ),但是卻不會造成爆棧(超過JavaScript引擎設定的呼叫棧最大容量)。

這種設計哲學可能會導致一些潛在的情況。例如下面的示例:

let bar;

// this has an asynchronous signature, but calls callback synchronously
function someAsyncApiCall(callback){callback();}

// the callback is called before `someAsyncApiCall` completes
someAsyncApiCall(()=>{
    console.log('bar',bar);
});

bar = 1;

使用者定義的someAsyncApiCall( )雖然從註釋上看是非同步的,但實際上是一個同步執行的函式。當它被呼叫時,回撥函式和someAsyncApiCall( )實際上處於事件迴圈的同一個階段,這裡並沒有任何實質上的非同步行為,結果就是,回撥函式嘗試獲取bar這個識別符號的值儘管作用域中並沒有為這個變數賦值,因為指令碼剩餘的部分並沒有執行完畢。

如果將回調函式替換為process.nextTick( )的形式,指令碼中剩餘的程式碼就可以執行完畢,這就使得變數和函式的初始化語句可以優先於傳入的回撥函式而被執行,這樣做的另一個好處是它不會推動事件迴圈前進。這就使得使用者可以在事件迴圈繼續進行之前對一些可能的告警或者錯誤進行處理。比如下面的例子:

let bar;

function someAsyncApiCall(callback) {
    process.nextTick(callback);
}

someAsyncApiCall(()=>{
   console.log('bar',bar); 
});

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( )setTimeout( )新增的回撥要更早觸發,但這種歷史問題是很難去修正的,它會導致一大批npm包無法正常運作。每天還有大量的新的模組釋出,這就意味著每過一天都有可能引發更多的破壞,儘管它們會造成混淆,但只能將錯就錯了。

我們推薦開發者在開發中堅持使用setImmediate( ),因為它的執行時機相對更容易推測(另外它也使得程式碼可以相容更多的環境例如瀏覽器JS)。

為什麼使用process.nextTick()

兩個最主要的理由是:

  1. 它允許使用者優先處理錯誤,清理任何後續階段不再使用的資源,或者在事件迴圈繼續進行之前嘗試重新發送請求。
  2. 有時也需要在呼叫棧並不為空時去執行一些回撥函式。

比如下面的示例:

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

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

設想listen()在事件迴圈開始時先執行,但是listening事件的監聽函式由setImmediate()來新增。除非傳入hostname,否則埠不會被繫結。對於事件迴圈來說,它一定會到達poll階段,如果此時已經有connection連線,那麼connection事件就會在poll階段被髮出,但listening事件要等到check階段能夠被髮出。

另一個示例是執行一個建構函式,它繼承了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!');
});