Node.js 的事件迴圈機制

forcheng發表於2020-04-18

目錄

  • 微任務
  • 事件迴圈機制
  • setImmediate、setTimeout/setInterval 和 process.nextTick 執行時機對比
  • 例項分析
  • 參考

1.微任務

在談論Node的事件迴圈機制之前,先補充說明一下 Node 中的“微任務”。這裡說的微任務(microtasks)其實是一個統稱,包含了兩部分:

  • process.nextTick() 註冊的回撥 (nextTick task queue)
  • promise.then() 註冊的回撥 (promise task queue)

Node 在執行微任務時, 會優先執行 nextTick task queue 中的任務,執行完之後會接著執行 promise task queue 中的任務。所以如果 process.nextTick 的回撥與 promise.then 的回撥都處於主執行緒或事件迴圈中的同一階段, process.nextTick 的回撥要優先於 promise.then 的回撥執行。


2.事件迴圈機制

Node事件迴圈

如圖,表示Node執行的整個過程。如果執行了任何非阻塞非同步程式碼(建立計時器、讀寫檔案等),則會進入事件迴圈。其中事件迴圈分為六個階段:

由於Pending callbacks、Idle/Prepare 和 Close callbacks 階段是 Node 內部使用的三個階段,所以這裡主要分析與開發者程式碼執行更為直接關聯的Timers、Poll 和 Check 三個階段。


Timers(計時器階段):從圖可見,初次進入事件迴圈,會從計時器階段開始。此階段會判斷是否存在過期的計時器回撥(包含 setTimeout 和 setInterval),如果存在則會執行所有過期的計時器回撥,執行完畢後,如果回撥中觸發了相應的微任務,會接著執行所有微任務,執行完微任務後再進入 Pending callbacks 階段。

Pending callbacks:執行推遲到下一個迴圈迭代的I / O回撥(系統呼叫相關的回撥)。

Idle/Prepare:僅供內部使用。(詳略)

Poll(輪詢階段)

當回撥佇列不為空時:

會執行回撥,若回撥中觸發了相應的微任務,這裡的微任務執行時機和其他地方有所不同,不會等到所有回撥執行完畢後才執行,而是針對每一個回撥執行完畢後,就執行相應微任務。執行完所有的回到後,變為下面的情況。

當回撥佇列為空時(沒有回撥或所有回撥執行完畢):

但如果存在有計時器(setTimeout、setInterval和setImmediate)沒有執行,會結束輪詢階段,進入 Check 階段。否則會阻塞並等待任何正在執行的I/O操作完成,並馬上執行相應的回撥,直到所有回撥執行完畢。

Check(查詢階段):會檢查是否存在 setImmediate 相關的回撥,如果存在則執行所有回撥,執行完畢後,如果回撥中觸發了相應的微任務,會接著執行所有微任務,執行完微任務後再進入 Close callbacks 階段。

Close callbacks:執行一些關閉回撥,比如 socket.on('close', ...)等。


總結&注意:

  1. 每一個階段都會有一個FIFO回撥佇列,都會盡可能的執行完當前階段中所有的回撥或到達了系統相關限制,才會進入下一個階段。
  2. Poll 階段執行的微任務的時機和 Timers 階段 & Check 階段的時機不一樣,前者是在每一個回撥執行就會執行相應微任務,而後者是會在所有回撥執行完之後,才統一執行相應微任務。

3.setImmediate、setTimeout/setInterval 和 process.nextTick 執行時機對比

setImmediate:觸發一個非同步回撥,在事件迴圈的 Check 階段立即執行。

setTimeout:觸發一個非同步回撥,當計時器過期後,在事件迴圈的 Timers 階段執行,只執行一次(可用 clearTimeout 取消)。

setInterval:觸發一個非同步回撥,每次計時器過期後,都會在事件迴圈的 Timers 階段執行一次回撥(可用 clearInterval 取消)。

process.nextTick:觸發一個微任務(非同步)回撥,既可以在主執行緒(mainline)中執行,可以存在事件循序的某一個階段中執行。


4.例項分析

第一組:

比較 setTimeout 與 setImmediate:

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

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

結果:

setTimeout vs setImmediate

分析:

從輸出結果來看,輸出是不確定的,既可能 "setTimeout" 在前,也可能 "setImmediate" 在前。從事件迴圈的流程來分析,事件迴圈開始,會先進入 Timers 階段,雖然 setTimeout 設定的 delay 是 0,但其實是1,因為 Node 中的 setTimeout 的 delay 取值範圍必須是在 [1, 2^31-1] 這個範圍內,否則預設為1,因此受程式效能的約束,執行到 Timers 階段時,可能計時器還沒有過期,所以繼續向下一個流程進行,所以會偶爾出現 "setImmediate" 輸出在前的情況。如果適當地調大 setTimeout 的 delay,比如10,則基本上必然是 "setImmediate" 輸出在前面。

第二組:

比較主執行緒(mainline)、Timers 階段、Poll 階段和 Check 階段的回撥執行以及對應的微任務執行的順序:

 // test.js
 const fs = require('fs');

 console.log('mainline: start')
 process.nextTick(() => {
   console.log('mainline: ', 'process.nextTick\n')
 })

let counter = 0;
const interval = setInterval(() => {
  console.log('timers: setInterval.start ', counter)
  if(counter < 2) {
    setTimeout(() => {
      console.log('timers: setInterval.setTimeout')
      process.nextTick(() => {
        console.log('timers microtasks: ', 'setInterval.setTimeout.process.nextTick\n')
      })
    }, 0)

    fs.readdir('./', (err, files) => {
      console.log('poll: setInterval.readdir1')
      process.nextTick(() => {
        console.log('poll microtasks: ', 'setInterval.readdir1.process.nextTick')
        process.nextTick(() => {
          console.log('poll microtasks: ', 'setInterval.readdir1.process.nextTick.process.nextTick')
        })
      })
    })

    fs.readdir('./', (err, files) => {
      console.log('poll: setInterval.readdir2')
      process.nextTick(() => {
        console.log('poll microtasks: ', 'setInterval.readdir2.process.nextTick')
        process.nextTick(() => {
          console.log('poll microtasks: ', 'setInterval.readdir2.process.nextTick.process.nextTick\n')
        })
      })
    })

    setImmediate(() => {
      console.log('check: setInterval.setImmediate1')
      process.nextTick(() => {
        console.log('check microtasks: ', 'setInterval.setImmediate1.process.nextTick')
      })
    })

    setImmediate(() => {
      console.log('check: setInterval.setImmediate2')
      process.nextTick(() => {
        console.log('check microtasks: ', 'setInterval.setImmediate2.process.nextTick\n')
      })
    })
  } else {
    console.log('timers: setInterval.clearInterval')
    clearInterval(interval)
  }

  console.log('timers: setInterval.end ', counter)
  counter++;
}, 0);

 console.log('mainline: end')

結果:

不同階段的回撥執行以及對應的微任務執行的順序

分析:

如圖 mainline:可以看到,主執行緒中的 process.nextTick 是在同步程式碼執行完之後以及在事件迴圈之前執行,符合預期。

如圖 第一次 timers:此時事件迴圈第一次到 Timers 階段,setInterval 的 delay 時間到了,所以執行回撥,由於沒有觸發直接相應的微任務,所以直接進入後面的階段。

如圖 第一次 poll:此時事件迴圈第一次到 Poll 階段,由於之前 Timers 階段執行的回撥中,觸發了兩個非阻塞的I/O操作(readdir),在這一階段時I/O操作執行完畢,直接執行了對應的兩個回撥。從輸出可以看出,針對每一個回撥執行完畢後,就執行相應微任務,微任務中再次觸發微任務也會繼續執行,並不會等到所有回撥執行完後再去觸發微任務,符合預期。執行完畢所有回撥之後,因為還有排程了計時器,所以 Poll 階段結束,進入 Check 階段。

如圖 第一次 check:此時事件迴圈第一次到 Check 階段,直接觸發對應的兩個 setImmediate 執行。從輸出可以看出,微任務是在所有的回撥執行完畢之後才觸發執行的,符合預期。執行完微任務後,進入後面階段。

如圖 第二次 timers:此時事件迴圈第二次到 Timers 階段,首先輸出了 "timers: setInterval.setTimeout" ,這是為什麼?不要忘了,之前第一次執行 setInterval 的回撥時,其實已經執行了一次其內部的 setTimeout(..., 0),但由於它並不能觸發微任務,所以其回撥沒有被執行,而是進入到了後面的階段,而是等到再次來到 Timers 階段,根據FIFO,優先執行之前的 setTimeout 的回撥,再執行 setInterval 的回撥,而最後等所有回撥執行完畢,再執行 setTimeout 的回撥裡面觸發的微任務,最後輸出的是 "timers microtasks: setInterval.setTimeout.process.nextTick",符合預期(所有回撥執行完畢後,再執行相應微任務)。

後面的輸出類似,所以不再做過多分析。


5.參考

學習 Node.js,第 5 單元:事件迴圈

事件迴圈、計時器和 process.nextTick()

相關文章