深入瞭解Node事件迴圈(EventLoop)機制

2023-03-16 22:00:22

主執行緒從"任務佇列"中讀取事件,這個過程是迴圈不斷的,所以整個的這種執行機制又稱為Event Loop(事件迴圈)。下面本篇文章就來帶大家掌握Node.js中的eventloop,希望對大家有所幫助!

雖然js可以在瀏覽器中執行又可以在node中執行,但是它們的事件迴圈機制並不是一樣的。並且有很大的區別。

EventLoop機制概述

在說Node事件迴圈機制之前,我們先來討論兩個問題

為什麼要學習事件迴圈機制?

學習事件迴圈可以讓開發者明白JavaScript的執行機制是怎麼樣的。

事件迴圈機制做的是什麼事情?

事件迴圈機制用於管理非同步API的回撥函數什麼時候回到主執行緒中執行

Node.js採用的是非同步IO模型。同步API在主執行緒中執行,非同步API在底層的C++維護的執行緒中執行,非同步API的回撥函數也會在主執行緒中執行。【相關教學推薦:、】

在Javascript應用執行時,眾多非同步API的回撥函數什麼時候能回到主執行緒中呼叫呢?這就是事件環環機制做的事情,管理非同步API的回撥函數什麼時候回到主執行緒中執行。

EventLoop的六個階段

Node中的事件迴圈分為六個階段。

在事件迴圈中的每個階段都有一個佇列,儲存要執行的回撥函數,事件迴圈機制會按照先進先出的方式執行他們直到佇列為空。

這六個階段都儲存著非同步回撥函數,所以還是遵循先執行主執行緒同步程式碼,當同步程式碼執行完後再來輪詢這六個階段。

接下來,我們來詳細看看這六個階段裡面儲存的都是什麼

Timers

Timers:用於儲存定時器的回撥函數(setlnterval,setTimeout)。

Pendingcallbacks

Pendingcallbacks:執行與作業系統相關的回撥函數,比如啟動伺服器端應用時監聽埠操作的回撥函數就在這裡呼叫。

idle,prepare

idle,prepare:系統內部使用。(這個我們程式設計師不用管)

Poll

Poll:儲存1/O操作的回撥函數佇列,比如檔案讀寫操作的回撥函數。

在這個階段需要特別注意,如果事件佇列中有回撥函數,則執行它們直到清空佇列 ,否則事件迴圈將在此階段停留一段時間以等待新的回撥函數進入。

但是對於這個等待並不是一定的,而是取決於以下兩個條件:

  • 如果setlmmediate佇列(check階段)中存在要執行的調函數。這種情況就不會等待。
  • timers佇列中存在要執行的回撥函數,在這種情況下也不會等待。事件迴圈將移至check階段,然後移至Closingcallbacks階段,並最終從timers階段進入下一次迴圈。

Check

Check:儲存setlmmediate的回撥函數。

Closingcallbacks

Closingcallbacks:執行與關閉事件相關的回撥,例如關閉資料庫連線的回撥函數等。

宏任務與微任務

跟瀏覽器中的js一樣,node中的非同步程式碼也分為宏任務和微任務,只是它們之間的執行順序有所區別。

我們再來看看Node中都有哪些宏任務和微任務

宏任務

  • setlnterval

  • setimeout

  • setlmmediate

  • I/O

微任務

  • Promise.then

  • Promise.catch

  • Promise.finally

  • process.nextTick

node中,對於微任務和宏任務的執行順序到底是怎樣的呢?

微任務和宏任務的執行順序

node中,微任務的回撥函數被放置在微任務佇列中,宏任務的回撥函數被放置在宏任務佇列中。

微任務優先順序高於宏任務。當微任務事件佇列中存在可以執行的回撥函數時,事件迴圈在執行完當前階段的回撥函數後會暫停進入事件迴圈的下一個階段,而會立即進入微任務的事件佇列中開始執行回撥函數,當微任務佇列中的回撥函數執行完成後,事件迴圈才會進入到下一個段開始執行回撥函數。

對於微任務我們還有個點需要特別注意。那就是雖然nextTick同屬於微任務,但是它的優先順序是高於其它微任務,在執行微任務時,只有nextlick中的所有回撥函數執行完成後才會開始執行其它微任務。

總的來說就是當主執行緒同步程式碼執行完畢後會優先清空微任務(如果微任務繼續產生微任務則會再次清空),然後再到下個事件迴圈階段。並且微任務的執行是穿插在事件迴圈六個階段中間的,也就是每次事件迴圈進入下個階段前會判斷微任務佇列是否為空,為空才會進入下個階段,否則先清空微任務佇列。

下面我們用程式碼實操來驗證前面所說的。

程式碼範例

先執行同步再執行非同步

Node應用程式啟動後,並不會立即進入事件迴圈,而是先執行同步程式碼,從上到下開始執行,同步API立即執行,非同步API交給C++維護的執行緒執行,非同步API的回撥函數被註冊到對應的事件佇列中。當所有同步程式碼執行完成後,才會進入事件迴圈。

console.log("start");

setTimeout(() => {
  console.log("setTimeout 1");
});

setTimeout(() => {
  console.log("setTimeout 2");
});

console.log("end");
登入後複製

我們來看執行結果

image.png

可以看到,先執行同步程式碼,然後才會進入事件迴圈執行非同步程式碼,在timers階段執行兩個setTimeout回撥。

setTimeout一定會先於setImmediate執行嗎

我們知道setTimeout是在timers階段執行,setImmediate是在check階段執行。並且事件迴圈是從timers階段開始的。所以會先執行setTimeout再執行setImmediate

對於上面的分析一定對嗎?

我們來看例子

console.log("start");

setTimeout(() => {
  console.log("setTimeout");
});

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

const sleep = (delay) => {
  const startTime = +new Date();
  while (+new Date() - startTime < delay) {
    continue;
  }
};

sleep(2000);
console.log("end");
登入後複製

執行上面的程式碼,輸出如下

image.png

先執行setTimeout再執行setImmediate

接下來我們來改造下上面的程式碼,把延遲器去掉,看看會輸出什麼

setTimeout(() => {
  console.log("setTimeout");
});

setImmediate(() => {
  console.log("setImmediate");
});
登入後複製

我們執行了七次,可以看到其中有兩次是先執行的setImmediate

image.png

怎麼回事呢?不是先timers階段再到check階段嗎?怎麼會變呢?

其實這就得看進入事件迴圈的時候,非同步回撥有沒有完全準備好了。對於最開始的例子,因為有2000毫秒的延遲,所以進入事件迴圈的時候,setTimeout回撥是一定準備好了的。所以執行順序不會變。但是對於這個例子,因為主執行緒沒有同步程式碼需要執行,所以一開始就進入事件迴圈,但是在進入事件迴圈的時候,setTimeout的回撥並不是一定完全準備好的,所以就會有先到check階段執行setImmediate回撥函數,再到下一次事件迴圈的timers階段來執行setTimeout的回撥。

那在什麼情況下同樣的延遲時間,setImmediate回撥函數一定會優先於setTimeout的回撥呢?

其實很簡單,只要將這兩者放到timers階段和check階段之間的Pendingcallbacks、idle,prepare、poll階段中任意一個階段就可以了。因為這些階段完執行完是一定會先到check再到timers階段的。

我們以poll階段為例,將這兩者寫在IO操作中。

const fs = require("fs");

fs.readFile("./fstest.js", "utf8", (err, data) => {
  setTimeout(() => {
    console.log("setTimeout");
  });

  setImmediate(() => {
    console.log("setImmediate");
  });
});
登入後複製

我們也來執行七次,可以看到,每次都是setImmediate先執行。

image.png

所以總的來說,同樣的延遲時間,setTimeout並不是百分百先於setImmediate執行。

先微任務再宏任務

主執行緒同步程式碼執行完畢後,會先執行微任務再執行宏任務。

我們來看下面的例子

console.log("start");

setTimeout(() => {
  console.log("setTimeout");
});

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

Promise.resolve().then(() => {
  console.log("Promise.resolve");
});

console.log("end");
登入後複製

我們執行一下看結果,可以看到它是先執行了微任務然後再執行宏任務

image.png

nextTick優於其它微任務

在微任務中nextTick的優先順序是最高的。

我們來看下面的例子

console.log("start");

setTimeout(() => {
  console.log("setTimeout");
});

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

Promise.resolve().then(() => {
  console.log("Promise.resolve");
});

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

console.log("end");
登入後複製

我們執行上面的程式碼,可以看到就算nextTick定義在resolve後面,它也是先執行的。

image.png

微任務穿插在各個階段間執行

怎麼理解這個穿插呢?其實就是在事件迴圈的六個階段每個階段執行完後會清空微任務佇列。

我們來看例子,我們建立了timers、check、poll三個階段,並且每個階段都產生了微任務。

// timers階段
setTimeout(() => {
  console.log("setTimeout");

  Promise.resolve().then(() => {
    console.log("setTimeout Promise.resolve");
  });
});

// check階段
setImmediate(() => {
  console.log("setImmediate");
  Promise.resolve().then(() => {
    console.log("setImmediate Promise.resolve");
  });
});

// 微任務
Promise.resolve().then(() => {
  console.log("Promise.resolve");
});

// 微任務
process.nextTick(() => {
  console.log("process.nextTick");
  Promise.resolve().then(() => {
    console.log("nextTick Promise.resolve");
  });
});
登入後複製

我們來執行上面的程式碼

image.png

可以看到,先執行微任務,再執行宏任務。先process.nextTick -> Promise.resolve。並且如果微任務繼續產生微任務則會再次清空,所以就又輸出了nextTick Promise.resolve

接下來到timer階段,輸出setTimeout,並且產生了一個微任務,再進入到下個階段前需要清空微任務佇列,所以繼續輸出setTimeout Promise.resolve

接下來到check階段,輸出setImmediate,並且產生了一個微任務,再進入到下個階段前需要清空微任務佇列,所以繼續輸出setImmediate Promise.resolve

這也就印證了微任務會穿插在各個階段之間執行。

image.png

總結

所以對於Node中的事件迴圈你只需要背好一以下幾點就可以了

  • 當主執行緒同步程式碼執行完畢後才會進入事件迴圈

  • 事件迴圈總共分六個階段,並且每個階段都包括哪些回撥需要記清楚。

  • 事件迴圈中會先執行微任務再執行宏任務。

  • 微任務會穿插在這六個階段之間執行,每進入到下個階段前會清空當前的微任務佇列。

  • 微任務中process.nextTick的優先順序最高,會優先執行。

更多node相關知識,請存取:!

以上就是深入瞭解Node事件迴圈(EventLoop)機制的詳細內容,更多請關注TW511.COM其它相關文章!