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

示例代碼託管在:http://www.github.com/dashnowords/blogs前端

博客園地址:《大史住在大前端》原創博文目錄node

華爲雲社區地址:【你要的前端打怪升級指南】git

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

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

Event Loop 是什麼?

事件循環是Node.js可以實現非阻塞I/O的基礎,儘管JavaScript應用是單線程運行的,可是它能夠將操做向下傳遞到系統內核去執行。api

大多數現代系統內核都是支持多線程的,它們能夠同時在後臺處理多個操做。當其中任何一個任務完成後,內核會通知Node.js,這樣它就能夠把對應的回調函數添加進poll隊列,回調函數最終就可以被執行,後文中咱們還會進行更詳細的解釋。瀏覽器

Event Loop 基本解釋

Node.js開始運行時,它就會初始化Event Loop,而後處理腳本文件(或者在REPLread-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!');
});
相關文章
相關標籤/搜索