Node.js 的事件循環機制

目錄

  • 微任務
  • 事件循環機制
  • 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.js 的事件循環機制

如圖,表示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:

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

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

結果:

Node.js 的事件循環機制

分析:

從輸出結果來看,輸出是不確定的,既可能 "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 階段的回調執行以及對應的微任務執行的順序:

<code>// 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')/<code>

結果:

Node.js 的事件循環機制

分析:

如圖 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",符合預期(所有回調執行完畢後,再執行相應微任務)。

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


分享到:


相關文章: