Nodejs事件迴圈小記

戡玉發表於2024-08-09

執行原理

  • 當 Node.js 啟動時,會先初始化 Event Loop,然後執行提供的輸入指令碼(主模組同步程式碼),過程中可能會產生非同步 API 呼叫、定時器或呼叫 process.nextTick(),然後開始處理事件迴圈。

  • Node.js 的 Event Loop 分為 6 個階段,會按照順序反覆執行,每當進入某一個階段的時候,都會從對應的回撥佇列中取出函式去執行。

  • 每執行完一個階段的回撥佇列,就會去執行 process.nextTick 和 Promise 2個微任務佇列,然後進入下一個階段,這就是 Node.js Event Loop 的過程。

  • 其中,process.nextTick 優先順序高於 Promise,但是如果當前執行的是 Promise,那麼如果執行過程中產生了 process.nextTick 和 Promise,那麼後續的 Promise 會先於 process.nextTick 執行,直到 Promise 微任務佇列清空。

階段概述

  • 定時器:此階段執行由 setTimeout() 和 setInterval() 安排的回撥。

  • 待處理回撥:執行被推遲到下一次迴圈迭代的 I/O 回撥。

  • 空閒,準備:僅在內部使用。

  • 輪詢:檢索新的 I/O 事件,執行 I/O 相關回撥(幾乎是除了關閉回撥、由定時器回撥和 setImmediate 回撥的所有回撥)。

  • 檢查:setImmediate() 回撥在此處呼叫。

  • 關閉回撥:一些關閉回撥,例如 socket.on('close', ...)。

需要關注的主要是定時器、輪詢、檢查和關閉回撥,重點是輪詢

  • 當沒有任何 I/O 任務時,事件迴圈會在輪詢階段等待,進入休眠期,直到新的 I/O 任務插入為止。

  • 當存在 I/O 任務時,Event Loop 會在清空輪詢佇列後,檢查 setImmediate佇列 和 到期定時器,如果存在,就會結束當前輪詢階段,進入下一個階段,最終執行它們。

相關 API

// 檢查階段執行的非同步函式
setImmediate()

// 當前 Event Loop 階段執行完畢後,下個 Event Loop 階段執行之前執行的非同步函式
// 從技術實現上來說,它不是事件迴圈的一部分
process.nextTick()

// V8 引擎語言層面實現的一種微任務函式,也不是事件迴圈的一部分
Promise

setImmediate() 與 setTimeout()

兩者的執行順序,根據呼叫它們的上下文而有所不同。

情況1:不存在全域性同步程式碼,直接在主模組執行時

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

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

// 執行順序不固定
// 原因:受到程序效能的約束(這可能會受到機器上執行的其他應用的影響)

情況2:存在全域性同步程式碼,直接在主模組執行時

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

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

console.log('start');

// start -> timeout -> immediate
// 原因:先執行同步程式碼(2個非同步函式已被加入各自佇列),再開始 Event Loop,定時器階段先於檢查階段,因此結果如上

情況3:.mjs程式碼,不存在全域性同步程式碼,直接在主模組執行時

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

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

// immediate -> timeout
// .mjs 本質上是一個 async 函式,因此實際程式碼等於:

async function main() {}
main().then(() => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);

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

// 原因:
// 1. 先執行同步程式碼 main(),再開始 Event Loop。
// 2. 第一輪迴圈中,定時器階段檢查,未發現佇列中有回撥函式,然後執行微任務 then 回撥。
// 3. 微任務回撥執行過程中,將 setTimeout 和 setImmediate 加入各自佇列。
// 4. 然後,第一輪迴圈繼續進行,終於達到了檢查階段,發現了檢查佇列中存在 setImmediate,執行。
// 5. 最後,第一輪迴圈執行完畢,開始第二輪迴圈,定時器階段檢查,發現了上輪加入的 setTimeout,執行

情況4:.mjs程式碼,存在全域性同步程式碼,直接在主模組執行時

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

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

console.log('start');

// start -> immediate-> timeout
// .mjs 本質上是一個 async 函式,因此實際程式碼等於:

async function main() {
  console.log('start');
}
main().then(() => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);

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

情況5:執行 I/O 任務,在 I/O 回撥中加入

// test.js 或 test.mjs
// 帶全域性程式碼 或 不帶全域性程式碼
const fs = require('node:fs');
fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});

// immediate -> timeout
// 原因:因為 I/O 回撥時,肯定處於輪詢階段,那麼下一個階段一定是檢查階段,所以,一定是 setImmediate 先執行。

相關文章