全面瞭解Node事件循環

Node事件循環

Node底層使用的語言libuv,是一個c++語言。他用來操作底層的操作系統,封裝瞭操作系統的接口。Node的事件循環也是用libuv來寫的,所以Node生命周期和瀏覽器的還是有區別的。

因為Node和操作系統打交道,所以事件循環比較復雜,也有一些自己特有的API。
事件循環在不同的操作系統裡有一些細微的差異。這將涉及到操作系統的知識,暫時不表。本次隻介紹JS主線程中,Node的運作流程。Node的其他線程暫時也不擴展。

事件循環圖

說好的一張圖,也不賣關子。下邊這張圖搞清楚瞭,事件循環就學會瞭。

事件循環圖

事件循環圖-結構

為瞭讓大傢先有個大局觀,先貼一張目錄結構圖在前邊

目錄

接下來詳細展開說說

主線程

主線程

上圖中,幾個色塊的含義:

  • main:啟動入口文件,運行主函數

  • event loop:檢查是否要進入事件循環

    檢查其他線程裡是否還有待處理事項

    檢查其他任務是否還在進行中(比如計時器、文件讀取操作等任務是否完成)

    有以上情況,進入事件循環,運行其他任務

  • 事件循環的過程:沿著從timers到close callbacks這個流程,走一圈。到event loop看是否結束,沒結束再走一圈。
  • over:所有的事情都完畢,結束

事件循環 圈

事件循環 圈

圖中灰色的圈跟操作系統有關系,不是本章解析重點。重點關註黃色、橙色的圈還有中間橘黃的方框。

我們把每一圈的事件循環叫做「一次循環」、又叫「一次輪詢」、又叫「一次Tick」。

一次循環要經過六個階段:

  1. timers:計時器(setTimeout、setInterval等的回調函數存放在裡邊)

  2. pending callback

  3. idle prepare

  4. poll:輪詢隊列(除timers、check之外的回調存放在這裡)

  5. check:檢查階段(使用 setImmediate 的回調會直接進入這個隊列

  6. close callbacks

本次我們隻關註上邊標紅的三個重點。

工作原理

  • 每一個階段都會維護一個事件隊列。可以把每一個圈想象成一個事件隊列。

  • 這就和瀏覽器不一樣瞭,瀏覽器最多兩個隊列(宏隊列、微隊列)。但是在node裡邊有六個隊列

  • 到達一個隊列後,檢查隊列內是否有任務(也就是看下是否有回調函數)需要執行。如果有,就依次執行,直到全部執行完畢、清空隊列。

  • 如果沒有任務,進入下一個隊列去檢查。直到所有隊列檢查一遍,算一個輪詢。

  • 其中,timerspending callbackidle prepare等執行完畢後,到達poll隊列。

timers隊列的工作原理

timers並非真正意義上的隊列,他內部存放的是計時器。
每次到達這個隊列,會檢查計時器線程內的所有計時器,計時器線程內部多個計時器按照時間順序排序。

檢查過程:將每一個計時器按順序分別計算一遍,計算該計時器開始計時的時間到當前時間是否滿足計時器的間隔參數設定(比如1000ms,計算計時器開始計時到現在是否有1m)。當某個計時器檢查通過,則執行其回調函數。

poll隊列的運作方式

  • 如果poll中有回調函數需要執行,依次執行回調,直到清空隊列。

  • 如果poll中沒有回調函數需要執行,已經是空隊列瞭。則會在這裡等待,等待其他隊列中出現回調,

如果其他隊列中出現回調,則從poll向下到over,結束該階段,進入下一階段。

如果其他隊列也都沒有回調,則持續在poll隊列等待,直到任何一個隊列出現回調後再進行工作。(是個小懶蟲的處事方式)

舉例梳理事件流程

setTimeout(() => {
  console.log('object');
}, 5000)
console.log('node');

以上代碼的事件流程梳理

  • 進入主線程,執行setTimeout(),回調函數作為異步任務被放入異步隊列timers隊列中,暫時不執行。

  • 繼續向下,執行定時器後邊的console,打印“node”。

  • 判斷是否有事件循環。是,走一圈輪詢:從timers – pending callback – idle prepare……

  • 到poll隊列停下循環並等待。

由於這時候沒到5秒,timers隊列無任務,所以一直在poll隊列卡著,同時輪詢檢查其他隊列是否有任務。

  • 等5秒到達,setTimeout的回調塞到timers內,例行輪詢檢查到timers隊列有任務,則向下走,經過check、close callbacks後到達timers。將timers隊列清空。

  • 繼續輪詢到poll等待,詢問是否還需要event loop,不需要,則到達over結束。

要理解這個問題,看下邊的代碼及流程解析:

setTimeout(function t1() {
  console.log('setTimeout');
}, 5000)
console.log('node 生命周期');

const http = require('http')

const server = http.createServer(function h1() {
  console.log('請求回調');
});

server.listen(8080)

代碼分析如下:

  • 照舊,先執行主線程,打印“node 生命周期”、引入http後創建http服務。

  • 然後event loop檢查是否有異步任務,檢查發現有定時器任務和請求任務。所以進入事件循環。

  • 六個隊列都沒任務,則在poll隊列等待。如下圖:

  • 過瞭五秒,timers中有瞭任務,則流程從poll放行向下,經過check和close callbacks隊列後,到達event loop。
  • event loop檢查是否有異步任務,檢查發現有定時器任務和請求任務。所以再次進入事件循環。

  • 到達timers隊列,發現有回調函數任務,則依次執行回調,清空timers隊列(當然這裡隻有一個5秒到達後的回調,所以直接執行完瞭即可),打印出“setTimeout”。如下圖

  • 清空timers隊列後,輪詢繼續向下到達poll隊列,由於poll隊列現在是空隊列,所以在這裡等待。
  • 後來,假設用戶請求發來瞭,h1回調函數被放到poll隊列。於是poll中有回調函數需要執行,依次執行回調,直到清空poll隊列。

  • poll隊列清空,此時poll隊列是空隊列,繼續等待。

  • 由於node線程一直holding在poll隊列,等很長一段時間還是沒有任務來臨時,會自動斷開等待(不自信表現),向下執行輪詢流程,經過check、close callbacks後到達event loop
  • 到瞭event loop後,檢查是否有異步任務,檢查發現有請求任務。(此時定時器任務已經執行完畢,所以沒有瞭),則繼續再次進入事件循環。

  • 到達poll隊列,再次holding……

  • 再等很長時間沒有任務來臨,自動斷開到even loop(再補充一點無任務的循環情況)

  • 再次回到poll隊列掛起

  • 無限循環……

梳理事件循環流程圖:

註意:下圖中的“是否有任務”的說法表示“是否有本隊列的任務”。

event loop流程梳理

再用一個典型的例子驗證下流程:

const startTime = new Date();

setTimeout(function f1() {
  console.log('setTimeout', new Date(), new Date() - startTime);
}, 200)

console.log('node 生命周期', startTime);

const fs = require('fs')

fs.readFile('./poll.js', 'utf-8', function fsFunc(err, data) {
  const fsTime = new Date()
  console.log('fs', fsTime);
  while (new Date() - fsTime < 300) {
  }
  console.log('結束死循環', new Date());
});

連續運行三遍,打印結果如下:

執行流程解析:

  1. 執行全局上下文,打印「node 生命周期 + 時間」

  2. 詢問是否有event loop

  3. 有,進入timers隊列,檢查沒有計時器(cpu處理速度可以,這時還沒到200ms)

  4. 輪詢進入到poll,讀文件還沒讀完(比如此時才用瞭20ms),因此poll隊列是空的,也沒有任務回調

  5. 在poll隊列等待……不斷輪詢看有沒有回調

  6. 文件讀完,poll隊列有瞭fsFunc回調函數,並且被執行,輸出「fs + 時間」

  7. 在while死循環那裡卡300毫秒,

  8. 死循環卡到200ms的時候,f1回調進入timers隊列。但此時poll隊列很忙,占用瞭線程,不會向下執行。

  9. 直到300ms後poll隊列清空,輸出「結束死循環 + 時間」

  10. event loop趕緊向下走

  11. 再來一輪到timers,執行timers隊列裡的f1回調。於是看到「setTimeout + 時間」

  12. timers隊列清空,回到poll隊列,沒有任務,等待一會。

  13. 等待時間夠長後,向下回到event loop。

  14. event loop檢查沒有其他異步任務瞭,結束線程,整個程序over退出。

check 階段

檢查階段(使用 setImmediate 的回調會直接進入這個隊列)

check隊列的實際工作原理

真正的隊列,裡邊扔的就是待執行的回調函數的集合。類似[fn,fn]這種形式的。
每次到達check這個隊列後,立即按順序執行回調函數即可【類似於[fn1,fn2].forEach((fn)=>fn())的感覺】

所以說,setImmediate不是一個計時器的概念。

如果你去面試,涉及到Node環節,可能會遇到下邊這個問題:setImmediate和setTimeout(0)誰更快。

setImmediate() 與 setTimeout(0) 的對比

  • setImmediate的回調是異步的,和setTimeout回調性質一致。

  • setImmediate回調在check隊列,setTimeout回調在timers隊列(概念意義,實際在計時器線程,隻是setTimeout在timers隊列做檢查調用而已。詳細看timers的工作原理)。

  • setImmediate函數調用後,回調函數會立即push到check隊列,並在下次eventloop時被執行。setTimeout函數調用後,計時器線程增加一個定時器任務,下次eventloop時會在timers階段裡檢查判斷定時器任務是否到達時間,到瞭則執行回調函數。

  • 綜上,setImmediate的運算速度比setTimeout(0)的要快,因為setTimeout還需要開計時器線程,並增加計算的開銷。

二者的效果差不多。但是執行順序不定

觀察以下代碼:

setTimeout(() => {
  console.log('setTimeout');
}, 0);

setImmediate(() => {
  console.log('setImmediate');
});

多次反復運行,執行效果如下:

順序不定

可以看到多次運行,兩句console.log打印的順序不定。
這是因為setTimeout的間隔數最小填1,雖然下邊代碼填瞭0。但實際計算機執行當1ms算。(這裡註意和瀏覽器的計時器區分。在瀏覽器中,setInterval的最小間隔數為10ms,小於10ms則會被設置為10;設備供電狀態下,間隔最小為16.6ms。)

以上代碼,主線程運行的時候,setTimeout函數調用,計時器線程增加一個定時器任務。setImmediate函數調用後,其回調函數立即push到check隊列。主線程執行完畢。

eventloop判斷時,發現timers和check隊列有內容,進入異步輪詢:

第一種情況:等到瞭timers裡這段時間,可能還沒有1ms的時間,定時器任務間隔時間的條件不成立所以timers裡還沒有回調函數。繼續向下到瞭check隊列裡,這時候setImmediate的回調函數早已等候多時,直接執行。而再下次eventloop到達timers隊列,定時器也早已成熟,才會執行setTimeout的回調任務。於是順序就是「setImmediate -> setTimeout」。

第二種情況:但也有可能到瞭timers階段時,超過瞭1ms。於是計算定時器條件成立,setTimeout的回調函數被直接執行。eventloop再向下到達check隊列執行setImmediate的回調。最終順序就是「setTimeout -> setImmediate」瞭。

所以,隻比較這兩個函數的情況下,二者的執行順序最終結果取決於當下計算機的運行環境以及運行速度

二者時間差距的對比代碼

------------------setTimeout測試:-------------------
let i = 0;
console.time('setTimeout');
function test() {
  if (i < 1000) {
    setTimeout(test, 0)
    i++
  } else {
    console.timeEnd('setTimeout');
  }
}
test();

------------------setImmediate測試:-------------------
let i = 0;
console.time('setImmediate');
function test() {
  if (i < 1000) {
    setImmediate(test)
    i++
  } else {
    console.timeEnd('setImmediate');
  }
}
test();

運行觀察時間差距:

setTimeout與setImmediate時間差距

可見setTimeout遠比setImmediate耗時多得多

這是因為setTimeout不僅有主代碼執行的時間消耗。還有在timers隊列裡,對於計時器線程中各個定時任務的計算時間。

結合poll隊列的面試題(考察timers、poll和check的執行順序)

如果你看懂瞭上邊的事件循環圖,下邊這道題難不倒你!

// 說說下邊代碼的執行順序,先打印哪個?
const fs = require('fs')
fs.readFile('./poll.js', () => {
  setTimeout(() => console.log('setTimeout'), 0)
  setImmediate(() => console.log('setImmediate'))
})

上邊這種代碼邏輯,不管執行多少次,肯定都是先執行setImmediate。

先執行setImmediate

因為fs各個函數的回調是放在poll隊列的。當程序holding在poll隊列後,出現回調立即執行。
回調內執行setTimeout和setImmediate的函數後,check隊列立即增加瞭回調。
回調執行完畢,輪詢檢查其他隊列有內容,程序結束poll隊列的holding向下執行。
check是poll階段的緊接著的下一個。所以在向下的過程中,先執行check階段內的回調,也就是先打印setImmediate。
到下一輪循環,到達timers隊列,檢查setTimeout計時器符合條件,則定時器回調被執行。

nextTick 與 Promise

說完宏任務,接下來說下微任務

  • 二者都是「微隊列」,執行異步微任務。

  • 二者不是事件循環的一部分,程序也不會開啟額外的線程去處理相關任務。(理解:promise裡發網絡請求,那是網絡請求開的網絡線程,跟Promise這個微任務沒關系)

  • 微隊列設立的目的就是讓一些任務「馬上」、「立即」優先執行。

  • nextTick與Promise比較,nextTick的級別更高。

nextTick表現形式

process.nextTick(() => {})

Promise表現形式

Promise.resolve().then(() => {})

如何參與事件循環?

事件循環中,每執行一個回調前,先按序清空一次nextTick和promise。

// 先思考下列代碼的執行順序
setImmediate(() => {
  console.log('setImmediate');
});

process.nextTick(() => {
  console.log('nextTick 1');
  process.nextTick(() => {
    console.log('nextTick 2');
  })
})

console.log('global');


Promise.resolve().then(() => {
  console.log('promise 1');
  process.nextTick(() => {
    console.log('nextTick in promise');
  })
})

最終順序:

  1. global

  2. nextTick 1

  3. nextTick 2

  4. promise 1

  5. nextTick in promise

  6. setImmediate

兩個問題:

基於上邊的說法,有兩個問題待思考和解決:

  1. 1.每走一個異步宏任務隊列就查一遍nextTick和promise?還是每執行完 宏任務隊列裡的一個回調函數就查一遍呢?

  2. 2.如果在poll的holding階段,插入一個nextTick或者Promise的回調,會立即停止poll隊列的holding去執行回調嗎?

上邊兩個問題,看下邊代碼的說法

setTimeout(() => {
  console.log('setTimeout 100');
  setTimeout(() => {
    console.log('setTimeout 100 - 0');
    process.nextTick(() => {
      console.log('nextTick in setTimeout 100 - 0');
    })
  }, 0)
  setImmediate(() => {
    console.log('setImmediate in setTimeout 100');
    process.nextTick(() => {
      console.log('nextTick in setImmediate in setTimeout 100');
    })
  });
  process.nextTick(() => {
    console.log('nextTick in setTimeout100');
  })
  Promise.resolve().then(() => {
    console.log('promise in setTimeout100');
  })
}, 100)

const fs = require('fs')
fs.readFile('./1.poll.js', () => {
  console.log('poll 1');
  process.nextTick(() => {
    console.log('nextTick in poll ======');
  })
})

setTimeout(() => {
  console.log('setTimeout 0');
  process.nextTick(() => {
    console.log('nextTick in setTimeout');
  })
}, 0)

setTimeout(() => {
  console.log('setTimeout 1');
  Promise.resolve().then(() => {
    console.log('promise in setTimeout1');
  })
  process.nextTick(() => {
    console.log('nextTick in setTimeout1');
  })
}, 1)

setImmediate(() => {
  console.log('setImmediate');
  process.nextTick(() => {
    console.log('nextTick in setImmediate');
  })
});

process.nextTick(() => {
  console.log('nextTick 1');
  process.nextTick(() => {
    console.log('nextTick 2');
  })
})

console.log('global ------');

Promise.resolve().then(() => {
  console.log('promise 1');
  process.nextTick(() => {
    console.log('nextTick in promise');
  })
})

/** 執行順序如下
global ------
nextTick 1
nextTick 2
promise 1
nextTick in promise
setTimeout 0 // 解釋問題1. 沒有上邊的nextTick和promise,setTimeout和setImmediate的順序不一定,有瞭以後肯定是0先開始。
// 可見,執行一個隊列之前,就先檢查並執行瞭nextTick和promise微隊列
nextTick in setTimeout
setTimeout 1
nextTick in setTimeout1
promise in setTimeout1
setImmediate
nextTick in setImmediate
poll 1
nextTick in poll ======
setTimeout 100
nextTick in setTimeout100
promise in setTimeout100
setImmediate in setTimeout 100
nextTick in setImmediate in setTimeout 100
setTimeout 100 - 0
nextTick in setTimeout 100 - 0
 */

以上代碼執行多次,順序不變,setTimeout和setImmediate的順序都沒變。

執行順序及具體原因說明如下:

  1. global :主線程同步任務,率先執行沒毛病

  2. nextTick 1:執行異步宏任務之前,清空異步微任務,nextTick優先級高,先行一步

  3. nextTick 2:執行完上邊這句代碼,又一個nextTick微任務,立即率先執行

  4. promise 1:執行異步宏任務之前,清空異步微任務,Promise的優先級低,所以在nextTick完瞭以後立即執行

  5. nextTick in promise:清空Promise隊列的過程中,遇到nextTick微任務,立即執行、清空
  6. setTimeout 0: 解釋第一個問題. 沒有上邊的nextTick和promise,隻有setTimeout和setImmediate時他倆的執行順序不一定。有瞭以後肯定是0先開始。可見,執行一個宏隊列之前,就先按順序檢查並執行瞭nextTick和promise微隊列。等微隊列全部執行完畢,setTimeout(0)的時機也成熟瞭,就被執行。

  7. nextTick in setTimeout:執行完上邊這句代碼,又一個nextTick微任務,立即率先執行 【這種回調函數裡的微任務,我不能確定是緊隨同步任務執行的;還是放到微任務隊列,等下一個宏任務執行前再清空的他們。但是順序看上去和立即執行他們一樣。不過我比較傾向於是後者:先放到微任務隊列等待,下一個宏任務執行前清空他們。

  8. setTimeout 1:因為執行微任務耗費時間,導致此時timers裡判斷兩個0和1的setTimeout計時器已經結束,所以兩個setTimeout回調都已加入隊列並被執行
  9. nextTick in setTimeout1:執行完上邊這句代碼,又一個nextTick微任務,立即率先執行 【可能是下一個宏任務前清空微任務】

  10. promise in setTimeout1:執行完上邊這句代碼,又一個Promise微任務,立即緊隨執行 【可能是下一個宏任務前清空微任務】

  11. setImmediate:poll隊列回調時機未到,先行向下到check隊列,清空隊列,立即執行setImmediate回調

  12. nextTick in setImmediate:執行完上邊這句代碼,又一個nextTick微任務,立即率先執行 【可能是下一個宏任務前清空微任務】

  13. poll 1:poll隊列實際成熟,回調觸發,同步任務執行。

  14. nextTick in poll :執行完上邊這句代碼,又一個nextTick微任務,立即率先執行 【可能是下一個宏任務前清空微任務】

  15. setTimeout 100:定時器任務到達時間,執行回調。並在回調裡往微任務推入瞭nextTick、Promise,往宏任務的check裡推入瞭setImmediate的回調。並且也開啟瞭計時器線程,往timers裡增加瞭下一輪回調的可能。

  16. nextTick in setTimeout100:宏任務向下前,率先執行定時器回調內新增的微任務-nextTick 【這裡就能確定瞭,是下一個宏任務前清空微任務的流程】

  17. promise in setTimeout100:緊接著執行定時器回調內新增的微任務-Promise 【清空完nextTick清空Promise的順序】

  18. setImmediate in setTimeout 100:這次setImmediate比setTimeout(0)先執行的原因是:流程從timers向後走到check隊列,已經有瞭setImmediate的回調,立即執行。

  19. nextTick in setImmediate in setTimeout 100:執行完上邊這句代碼,又一個nextTick微任務,下一個宏任務前率先清空微任務

  20. setTimeout 100 - 0:輪詢又一次回到timers,執行100-0的回調。

  21. nextTick in setTimeout 100 - 0:執行完上邊這句代碼,又一個nextTick微任務,下一個宏任務前率先清空微任務。

擴展:為什麼有瞭setImmediate還要有nextTick和Promise?

一開始設計的時候,setImmediate充當瞭微隊列的作用(雖然他不是)。設計者希望執行完poll後立即執行setImmediate(當然現在也確實是這麼表現的)。所以起的名字叫Immediate,表示立即的意思。但是後來問題是,poll裡可能有N個任務連續執行,在執行期間想要執行setImmediate是不可能的。因為poll隊列不停,流程不向下執行。

於是出現nextTick,真正的微隊列概念。但此時,immediate的名字被占用瞭,所以名字叫nextTick(下一瞬間)。事件循環期間,執行任何一個隊列之前,都要檢查他是否被清空。其次是Promise。

面試題

最後,檢驗學習成果的面試題來瞭

async function async1() {
  console.log('async start');
  await async2();
  console.log('async end');
}

async function async2(){
  console.log('async2');
}
console.log('script start');

setTimeout(() => {
  console.log('setTimeout 0');
}, 0)

setTimeout(() => {
  console.log('setTimeout 3');
}, 3)

setImmediate(() => {
  console.log('setImmediate');
})

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

async1();

new Promise((res) => {
  console.log('promise1');
  res();
  console.log('promise2');
}).then(() => {
  console.log('promise 3');
});

console.log('script end');

// 答案如下
// -
// -
// -
// -
// -
// -
// -
// -
// -
// -
// -
// -






/**
script start
async start
async2
promise1
promise2
script end

nextTick
async end
promise 3

// 後邊這仨的運行順序就是驗證你電腦運算速度的時候瞭。
速度最好(執行上邊的同步代碼 + 微任務 + 計時器運算用瞭不到0ms):
setImmediate
setTimeout 0
setTimeout 3

速度中等(執行上邊的同步代碼 + 微任務 + 計時器運算用瞭0~3ms以上):
setTimeout 0
setImmediate
setTimeout 3

速度較差(執行上邊的同步代碼 + 微任務 + 計時器運算用瞭3ms以上):
setTimeout 0
setTimeout 3
setImmediate
*/

思維腦圖

 Node生命周期核心階段      

到此這篇關於全面瞭解Node事件循環的文章就介紹到這瞭,更多相關Node事件循環內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: