深刻理解NodeJS事件循環機制

導讀

ALL THE TIME,咱們寫的的大部分javascript代碼都是在瀏覽器環境下編譯運行的,所以可能咱們對瀏覽器的事件循環機制瞭解比Node.JS的事件循環更深刻一些,可是最近寫開始深刻NodeJS學習的時候,發現NodeJS的事件循環機制和瀏覽器端有很大的區別,特此記錄來深刻的學習了下,以幫助本身及小夥伴們忘記後查閱及理解。javascript

參考資料:java

在這裏插入圖片描述

什麼是事件循環

首先咱們須要瞭解一下最基礎的一些東西,好比這個事件循環,事件循環是指Node.js執行非阻塞I/O操做,儘管==JavaScript是單線程的==,但因爲大多數==內核都是多線程==的,Node.js會盡量將操做裝載到系統內核。所以它們能夠處理在後臺執行的多個操做。當其中一個操做完成時,內核會告訴Node.js,以便Node.js能夠將相應的回調添加到輪詢隊列中以最終執行。node

當Node.js啓動時會初始化event loop, 每個event loop都會包含按以下順序六個循環階段:segmentfault

┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
複製代碼
  • 1. timers 階段: 這個階段執行 setTimeout(callback)setInterval(callback) 預約的 callback;
  • 2. I/O callbacks 階段: 此階段執行某些系統操做的回調,例如TCP錯誤的類型。 例如,若是TCP套接字在嘗試鏈接時收到 ECONNREFUSED,則某些* nix系統但願等待報告錯誤。 這將操做將等待在==I/O回調階段==執行;
  • 3. idle, prepare 階段: 僅node內部使用;
  • 4. poll 階段: 獲取新的I/O事件, 例如操做讀取文件等等,適當的條件下node將阻塞在這裏;
  • 5. check 階段: 執行 setImmediate() 設定的callbacks;
  • 6. close callbacks 階段: 好比 socket.on(‘close’, callback) 的callback會在這個階段執行;

事件循環詳解

在這裏插入圖片描述
這個圖是整個 Node.js 的運行原理,從左到右,從上到下,Node.js 被分爲了四層,分別是 應用層V8引擎層Node API層LIBUV層

  • 應用層: 即 JavaScript 交互層,常見的就是 Node.js 的模塊,好比 http,fs
  • V8引擎層: 即利用 V8 引擎來解析JavaScript 語法,進而和下層 API 交互
  • NodeAPI層: 爲上層模塊提供系統調用,通常是由 C 語言來實現,和操做系統進行交互 。
  • LIBUV層: 是跨平臺的底層封裝,實現了 事件循環、文件操做等,是 Node.js 實現異步的核心 。

每一個循環階段內容詳解

timers階段 一個timer指定一個下限時間而不是準確時間,在達到這個下限時間後執行回調。在指定時間事後,timers會盡量早地執行回調,但系統調度或者其它回調的執行可能會延遲它們。promise

  • 注意:技術上來講,poll 階段控制 timers 何時執行。瀏覽器

  • 注意:這個下限時間有個範圍:[1, 2147483647],若是設定的時間不在這個範圍,將被設置爲1。bash

I/O callbacks階段 這個階段執行一些系統操做的回調。好比TCP錯誤,如一個TCP socket在想要鏈接時收到ECONNREFUSED, 類unix系統會等待以報告錯誤,這就會放到 I/O callbacks 階段的隊列執行. 名字會讓人誤解爲執行I/O回調處理程序, 實際上I/O回調會由poll階段處理.多線程

poll階段 poll 階段有兩個主要功能:(1)執行下限時間已經達到的timers的回調,(2)而後處理 poll 隊列裏的事件。 當event loop進入 poll 階段,而且 沒有設定的 timers(there are no timers scheduled),會發生下面兩件事之一:異步

  • 若是 poll 隊列不空,event loop會遍歷隊列並同步執行回調,直到隊列清空或執行的回調數到達系統上限;socket

  • 若是 poll 隊列爲空,則發生如下兩件事之一:

    • 若是代碼已經被setImmediate()設定了回調, event loop將結束 poll 階段進入 check 階段來執行 check 隊列(裏面的回調 callback)。
    • 若是代碼沒有被setImmediate()設定回調,event loop將阻塞在該階段等待回調被加入 poll 隊列,並當即執行。
  • 可是,當event loop進入 poll 階段,而且 有設定的timers,一旦 poll 隊列爲空(poll 階段空閒狀態): event loop將檢查timers,若是有1個或多個timers的下限時間已經到達,event loop將繞回 timers 階段,並執行 timer 隊列。

check階段 這個階段容許在 poll 階段結束後當即執行回調。若是 poll 階段空閒,而且有被setImmediate()設定的回調,event loop會轉到 check 階段而不是繼續等待。

  • setImmediate() 其實是一個特殊的timer,跑在event loop中一個獨立的階段。它使用libuv的API 來設定在 poll 階段結束後當即執行回調。

  • 一般上來說,隨着代碼執行,event loop終將進入 poll 階段,在這個階段等待 incoming connection, request 等等。可是,只要有被setImmediate()設定了回調,一旦 poll 階段空閒,那麼程序將結束 poll 階段並進入 check 階段,而不是繼續等待 poll 事件們 (poll events)。

close callbacks 階段 若是一個 socket 或 handle 被忽然關掉(好比 socket.destroy()),close事件將在這個階段被觸發,不然將經過process.nextTick()觸發

在這裏插入圖片描述
這裏呢,咱們經過僞代碼來講明一下,這個流程:

// 事件循環自己至關於一個死循環,當代碼開始執行的時候,事件循環就已經啓動了
// 而後順序調用不一樣階段的方法
while(true){
// timer階段
	timer()
// I/O callbacks階段
	IO()
// idle階段
	IDLE()
// poll階段
	poll()
// check階段
	check()
// close階段
	close()
}
// 在一次循環中,當事件循環進入到某一階段,加入進入到check階段,忽然timer階段的事件就緒,也會等到當前此次循環結束,再去執行對應的timer階段的回調函數 
// 下面看這裏例子
const fs = require('fs')

// timers階段
const startTime = Date.now();
setTimeout(() => {
    const endTime = Date.now()
    console.log(`timers: ${endTime - startTime}`)
}, 1000)

// poll階段(等待新的事件出現)
const readFileStart =  Date.now();
fs.readFile('./Demo.txt', (err, data) => {
    if (err) throw err
    let endTime = Date.now()
    // 獲取文件讀取的時間
    console.log(`read time: ${endTime - readFileStart}`)
    // 經過while循環將fs回調強制阻塞5000s
    while(endTime - readFileStart < 5000){
        endTime = Date.now()
    }

})


// check階段
setImmediate(() => {
    console.log('check階段')
})
/*控制檯打印 check階段 read time: 9 timers: 5008 經過上述結果進行分析, 1.代碼執行到定時器setTimeOut,目前timers階段對應的事件列表爲空,在1000s後纔會放入事件 2.事件循環進入到poll階段,開始不斷的輪詢監聽事件 3.fs模塊異步執行,根據文件大小,可能執行時間長短不一樣,這裏我使用的小文件,事件大概在9s左右 4.setImmediate執行,poll階段暫時未監測到事件,發現有setImmediate函數,跳轉到check階段執行check階段事件(打印check階段),第一次時間循環結束,開始下一輪事件循環 5.由於時間仍未到定時器截止時間,因此事件循環有一次進入到poll階段,進行輪詢 6.讀取文件完畢,fs產生了一個事件進入到poll階段的事件隊列,此時事件隊列準備執行callback,因此會打印(read time: 9),人工阻塞了5s,雖然此時timer定時器事件已經被添加,可是由於這一階段的事件循環爲完成,因此不會被執行,(若是這裏是死循環,那麼定時器代碼永遠沒法執行) 7.fs回調阻塞5s後,當前事件循環結束,進入到下一輪事件循環,發現timer事件隊列有事件,因此開始執行 打印timers: 5008 ps: 1.將定時器延遲時間改成5ms的時候,小於文件讀取時間,那麼就會先監聽到timers階段有事件進入,從而進入到timers階段執行,執行完畢繼續進行事件循環 check階段 timers: 6 read time: 5008 2.將定時器事件設置爲0ms,會在進入到poll階段的時候發現timers階段已經有callback,那麼會直接執行,而後執行完畢在下一階段循環,執行check階段,poll隊列的回調函數 timers: 2 check階段 read time: 7 */
複製代碼

走進案例解析

咱們來看一個簡單的EventLoop的例子:

const fs = require('fs');
let counts = 0;

// 定義一個 wait 方法
function wait (mstime) {
  let date = Date.now();
  while (Date.now() - date < mstime) {
    // do nothing
  }
}

// 讀取本地文件 操做IO
function asyncOperation (callback) {
  fs.readFile(__dirname + '/' + __filename, callback);
}

const lastTime = Date.now();

// setTimeout
setTimeout(() => {
  console.log('timers', Date.now() - lastTime + 'ms');
}, 0);

// process.nextTick
process.nextTick(() => {
  // 進入event loop
  // timers階段以前執行
  wait(20);
  asyncOperation(() => {
    console.log('poll');
  });  
});

/** * timers 21ms * poll */
複製代碼

這裏呢,爲了讓這個setTimeout優先於fs.readFile 回調, 執行了process.nextTick, 表示在進入timers階段前, 等待20ms後執行文件讀取.

1. nextTicksetImmediate

  • process.nextTick 不屬於事件循環的任何一個階段,它屬於該階段與下階段之間的過渡, 即本階段執行結束, 進入下一個階段前, 所要執行的回調。有給人一種插隊的感受.

  • setImmediate 的回調處於check階段, 當poll階段的隊列爲空, 且check階段的事件隊列存在的時候,切換到check階段執行.

nextTick 遞歸的危害

因爲nextTick具備插隊的機制,nextTick的遞歸會讓事件循環機制沒法進入下一個階段. 致使I/O處理完成或者定時任務超時後仍然沒法執行, 致使了其它事件處理程序處於飢餓狀態. 爲了防止遞歸產生的問題, Node.js 提供了一個 process.maxTickDepth (默認 1000)。

const fs = require('fs');
let counts = 0;

function wait (mstime) {
  let date = Date.now();
  while (Date.now() - date < mstime) {
    // do nothing
  }
}

function nextTick () {
  process.nextTick(() => {
    wait(20);
    console.log('nextTick');
    nextTick();
  });
}

const lastTime = Date.now();

setTimeout(() => {
  console.log('timers', Date.now() - lastTime + 'ms');
}, 0);

nextTick();
複製代碼

此時永遠沒法跳到timer階段去執行setTimeout裏面的回調方法, 由於在進入timers階段前有不斷的nextTick插入執行. 除非執行了1000次到了執行上限,因此上面這個案例會不斷地打印出nextTick字符串

2. setImmediate

若是在一個I/O週期內進行調度,setImmediate() 將始終在任何定時器(setTimeout、setInterval)以前執行.

3. setTimeoutsetImmediate

  • setImmediate()被設計在 poll 階段結束後當即執行回調;
  • setTimeout()被設計在指定下限時間到達後執行回調;

無 I/O 處理狀況下:

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

setImmediate(function immediate () {
  console.log('immediate');
});
複製代碼

執行結果:

C:\Users\92809\Desktop\node_test>node test.js
timeout
immediate

C:\Users\92809\Desktop\node_test>node test.js
timeout
immediate

C:\Users\92809\Desktop\node_test>node test.js
timeout
immediate

C:\Users\92809\Desktop\node_test>node test.js
immediate
timeout
複製代碼

從結果,咱們能夠發現,這裏打印輸出出來的結果,並無什麼固定的前後順序,偏向於隨機,爲何會發生這樣的狀況呢?

答:首先進入的是timers階段,若是咱們的機器性能通常,那麼進入timers階段,1ms已通過去了 ==(setTimeout(fn, 0)等價於setTimeout(fn, 1))==,那麼setTimeout的回調會首先執行。

若是沒有到1ms,那麼在timers階段的時候,下限時間沒到,setTimeout回調不執行,事件循環來到了poll階段,這個時候隊列爲空,因而往下繼續,先執行了setImmediate()的回調函數,以後在下一個事件循環再執行setTimemout的回調函數。

問題總結:而咱們在==執行啓動代碼==的時候,進入timers的時間延遲實際上是==隨機的==,並非肯定的,因此會出現兩個函數執行順序隨機的狀況。

那咱們再來看一段代碼:

var fs = require('fs')

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout');
    }, 0);
    setImmediate(() => {
        console.log('immediate');
    });
});
複製代碼

打印結果以下:

C:\Users\92809\Desktop\node_test>node test.js
immediate
timeout

C:\Users\92809\Desktop\node_test>node test.js
immediate
timeout

C:\Users\92809\Desktop\node_test>node test.js
immediate
timeout

# ... 省略 n 屢次使用 node test.js 命令 ,結果都輸出 immediate timeout
複製代碼

這裏,爲啥和上面的隨機timer不一致呢,咱們來分析下緣由:

緣由以下:fs.readFile的回調是在poll階段執行的,當其回調執行完畢以後,poll隊列爲空,而setTimeout入了timers的隊列,此時有代碼 setImmediate(),因而事件循環先進入check階段執行回調,以後在下一個事件循環再在timers階段中執行回調。

固然,下面的小案例同理:

setTimeout(() => {
    setImmediate(() => {
        console.log('setImmediate');
    });
    setTimeout(() => {
        console.log('setTimeout');
    }, 0);
}, 0);
複製代碼

以上的代碼在timers階段執行外部的setTimeout回調後,內層的setTimeoutsetImmediate入隊,以後事件循環繼續日後面的階段走,走到poll階段的時候發現隊列爲空,此時有代碼有setImmedate(),因此直接進入check階段執行響應回調(==注意這裏沒有去檢測timers隊列中是否有成員到達下限事件,由於setImmediate()優先==)。以後在第二個事件循環的timers階段中再去執行相應的回調。

綜上所演示,咱們能夠總結以下:

  • 若是二者都在主模塊中調用,那麼執行前後取決於進程性能,也就是你的電腦好撇,固然也就是隨機。
  • 若是二者都不在主模塊調用(被一個異步操做包裹),那麼**setImmediate的回調永遠先執行**。

4. nextTickPromise

概念:對於這兩個,咱們能夠把它們理解成一個微任務。也就是說,它其實不屬於事件循環的一部分。 那麼他們是在何時執行呢? 無論在什麼地方調用,他們都會在其所處的事件循環最後,事件循環進入下一個循環的階段前執行。

setTimeout(() => {
    console.log('timeout0');
    new Promise((resolve, reject) => { resolve('resolved') }).then(res => console.log(res));
    new Promise((resolve, reject) => {
      setTimeout(()=>{
        resolve('timeout resolved')
      })
    }).then(res => console.log(res));
    process.nextTick(() => {
        console.log('nextTick1');
        process.nextTick(() => {
            console.log('nextTick2');
        });
    });
    process.nextTick(() => {
        console.log('nextTick3');
    });
    console.log('sync');
    setTimeout(() => {
        console.log('timeout2');
    }, 0);
}, 0);
複製代碼

控制檯打印以下:

C:\Users\92809\Desktop\node_test>node test.js
timeout0
sync
nextTick1
nextTick3
nextTick2
resolved
timeout2
timeout resolved
複製代碼

最總結:timers階段執行外層setTimeout的回調,遇到同步代碼先執行,也就有timeout0sync的輸出。遇到process.nextTickPromise後入微任務隊列,依次nextTick1nextTick3nextTick2resolved入隊後出隊輸出。以後,在下一個事件循環的timers階段,執行setTimeout回調輸出timeout2以及微任務Promise裏面的setTimeout,輸出timeout resolved。(這裏要說明的是 微任務nextTick優先級要比Promise要高)

5. 最後案例

代碼片斷1:

setImmediate(function(){
  console.log("setImmediate");
  setImmediate(function(){
    console.log("嵌套setImmediate");
  });
  process.nextTick(function(){
    console.log("nextTick");
  })
});

/* C:\Users\92809\Desktop\node_test>node test.js setImmediate nextTick 嵌套setImmediate */
複製代碼

解析:

事件循環check階段執行回調函數輸出setImmediate,以後輸出nextTick。嵌套的setImmediate在下一個事件循環的check階段執行回調輸出嵌套的setImmediate

代碼片斷2:

async function async1(){
    console.log('async1 start')
    await async2()
    console.log('async1 end')
  }
async function async2(){
    console.log('async2')
}
console.log('script start')
setTimeout(function(){
    console.log('setTimeout0') 
},0)  
setTimeout(function(){
    console.log('setTimeout3') 
},3)  
setImmediate(() => console.log('setImmediate'));
process.nextTick(() => console.log('nextTick'));
async1();
new Promise(function(resolve){
    console.log('promise1')
    resolve();
    console.log('promise2')
}).then(function(){
    console.log('promise3')
})
console.log('script end')
複製代碼

打印結果爲:

C:\Users\92809\Desktop\node_test>node test.js
script start
async1 start
async2
promise1
promise2
script end
nextTick
promise3
async1 end
setTimeout0
setTimeout3
setImmediate
複製代碼

你們呢,能夠先看着代碼,默默地在心底走一變代碼,而後對比輸出的結果,固然最後三位,我我的認爲是有點問題的,畢竟在主模塊運行,你們的答案,最後三位可能會有誤差;

相關文章
相關標籤/搜索