一張圖帶你搞懂Node事件迴圈

xing.org1^發表於2021-08-24

說一件重要的事兒:你還沒關注公眾號【前端印記】,更多精彩內容等你探索……

以下全文7000字,請在你思路清晰、精力充沛的時刻觀看。保證你理解後很長時間忘不掉。

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流程梳理
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遠比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
先執行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. 每走一個非同步巨集任務佇列就查一遍nextTick和promise?還是每執行完 巨集任務佇列裡的一個回撥函式就查一遍呢?
  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生命週期核心階段
終於結束了
終於結束了

相關文章