Node-js 事件循環的完整指南

原文作者:Joseph Mawa

原文地址:https://blog.logrocket.com/complete-guide-node-js-event-loop/

翻譯:一川

Node.js 是一個單線程、非阻塞、事件驅動的 JavaScript 運行時環境。Node 運行時環境使您能夠在服務器端的瀏覽器之外運行 JavaScript

Node.js 的異步和非阻塞功能主要由事件循環編排。在本文中,您將學習 Node.js 事件循環,以便可以利用其異步 API 來構建高效的 Node.js 應用程序。瞭解事件循環在內部的工作方式不僅可以幫助您編寫健壯且高性能的Node.js代碼,而且還會教您有效地調試性能問題。

什麼是 Node.js 中的事件循環?

Node.js 事件循環是一個連續運行的半無限循環。只要存在掛起的異步操作,它就會運行。使用該 node 命令啓動 Node.js 進程將執行您的 JavaScript 代碼並初始化事件循環。如果 Node.js 在執行腳本時遇到異步操作(如計時器、文件和網絡 I/O),則會將該操作卸載到本機系統或線程池。

大多數 I/O 操作(如讀取和寫入文件、文件加密和解密以及網絡)都非常耗時且計算成本高昂。因此,爲了避免阻塞主線程,Node.js 將這些操作卸載到本機系統。在那裏,節點進程正在運行,因此係統並行處理這些操作。

大多數現代操作系統內核在設計上都是多線程的。因此,操作系統可以併發處理多個操作,並在這些操作完成時通知 Node.js。事件循環負責執行異步 API 回調。它有六個主要階段:

雖然上面的列表是線性的,但事件循環是循環和迭代的,如下圖所示:

在事件循環的最後一個階段後,如果仍有掛起的事件或異步操作,則事件循環的下一次迭代將開始。否則,它將退出,並且 Node.js 進程結束。

我們將在以下各節中詳細探討事件循環的每個階段。在此之前,讓我們探索上圖中出現在事件循環中心的 “下一個即時報價” 和微任務隊列。從技術上講,它們不是事件循環的一部分。

Node.js 中的微任務隊列

PromisequeueMicrotaskprocess.nextTick 都是 Node.js 中異步 API 的一部分。當Promise結束時, queueMicrotask.then.catch 以及.finally 回調被添加到微任務隊列中。

另一方面,process.nextTick回調屬於nextTick隊列。讓我們使用下面的示例來說明如何處理微任務和nextTick隊列:

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

  Promise.resolve("Promise 1").then(console.log);
  Promise.reject("Promise 2").catch(console.log);
  queueMicrotask(() => console.log("queueMicrotask 1"));

  process.nextTick(console.log, "nextTick 1");
}, 0);

setTimeout(console.log, 0, "setTimeout 2");

setTimeout(console.log, 0, "setTimeout 3");

假設上面的三個計時器同時過期。當事件循環進入計時器階段時,它會將過期的計時器添加到計時器回調隊列中,並從第一個到最後一個執行它們:

在上面的示例代碼中,當執行計時器隊列中的第一個回調時, .then.catchqueueMicrotask 回調會添加到微任務隊列中。類似地, process.nextTick 回調被添加到一個隊列中,我們將該隊列稱爲nextTick隊列。注意, console.log 是同步的。

timers隊列的第一個回調返回時,將處理nextTick隊列。如果在處理nextTick隊列中的回調時生成了更多的nextTick,它們將被添加到nextTick隊列的後面並執行。

nextTick隊列爲空時,接下來處理微任務隊列。如果微任務生成更多微任務,它們也會被添加到微任務隊列的後面並執行。

nextTick隊列和微任務隊列都爲空時,事件循環會在計時器隊列中執行第二個回調。相同的過程將繼續,直到計時器隊列爲空:

上述過程不限於timers階段。當事件循環在所有其他主要階段執行 JavaScript 時,nextTick隊列和微任務隊列的處理方式類似。

Node.js 的事件循環階段

如上所述,Node.js 事件循環是一個具有六個主要階段的半無限循環。它有更多的階段,但事件循環的一些階段進行內部管理。它們對您編寫的代碼沒有直接影響。因此,我們不會在這裏介紹它們。

事件循環中的每個主要階段都有一個先進先出的回調隊列。例如,操作系統將運行scheduledtimers,直到它們過期。之後,過期的timers將添加到timers回調隊列中。

然後,事件循環在timers隊列中執行回調,直到隊列爲空或達到最大回調數。我們將在以下部分中探討事件循環的主要階段。

定時器階段

與瀏覽器一樣,Node.js 具有計時器 API,用於調度將來將執行的功能。Node.js 中的計時器 API 類似於瀏覽器中的計時器 API。但是,存在一些細微的實現差異。

計時器 API 由 setTimeoutsetIntervalsetImmediate 函數組成。所有三個計時器都是異步的。事件循環的計時器階段只負責處理 setTimeoutsetInterval

另一方面,check階段負責 setImmediate 該功能。稍後我們將探討check階段。 setTimeoutsetInterval兩者都具有以下功能簽名:

setTimeout(callback[, delay[, ...args]])
setInterval(callback[, delay[, ...args]])

使用 setTimeoutcallback 在經過時 delay 調用一次。另一方面, setInterval scheduledcallback 每毫秒運行一次 delay

下圖顯示了刪除除計時器階段之外的所有階段後的事件循環:

爲簡單起見,讓我們採取三個同時過期 setTimeoutscheduled。以下步驟描述了當事件循環進入計時器階段時會發生什麼情況:

在上面的步驟中,我們使用了由三個過期計時器組成的隊列。然而,在實踐中並非總是如此。事件循環將處理計時器隊列,直到它爲空或達到最大回調數,然後再進入下一階段。

執行 JavaScript 回調時,事件循環被阻塞。如果回調需要很長時間來處理,則事件循環將等待直到返回。由於 Node.js 主要在服務器端運行,因此阻塞事件循環將導致性能問題。

同樣,傳遞給計時器函數的delay參數並不總是執行 setTimeoutsetInterval 回調之前的確切等待時間。這是最短的等待時間。所需持續時間取決於事件循環的繁忙程度以及所使用的系統計時器。

Pending 回調

在輪詢階段(我們將在稍後介紹)期間,事件循環輪詢文件和網絡 I/O 操作等事件。事件循環處理輪詢階段中的一些輪詢事件,並將特定事件推遲到事件循環的下一次迭代中的掛起階段。

在掛起階段,事件循環將延遲的事件添加到掛起的回調隊列並執行它們。在掛起回調階段處理的事件包括系統發出的某些 TCP 套接字錯誤。例如,某些操作系統將 ECONNREFUSED 錯誤事件的處理推遲到此階段。

Idle 和 prepare

事件循環使用Idleprepare階段進行內部內務處理操作。它不會直接影響您編寫的 Node.js 代碼。雖然我們不會詳細探討它,但有必要知道它的存在。

輪詢階段

輪詢階段有兩個功能。第一種是處理輪詢隊列中的事件並執行其回調。第二個函數是確定阻塞事件循環和輪詢 I/O 事件的時間。

當事件循環進入輪詢階段時,它會將掛起的 I/O 事件排隊並執行它們,直到隊列爲空或達到與系統相關的限制。在執行 JavaScript 回調之間,“nextTick” 和微任務隊列被耗盡,就像在其他階段一樣。

輪詢階段與其他階段之間的區別在於,事件循環有時會在一段時間內阻塞事件循環並輪詢 I/O 事件,直到超時結束或達到最大回調限制後。

事件循環在決定是否阻塞事件循環以及阻塞事件循環多長時間時會考慮多個因素。其中一些因素包括掛起的 I/O 事件的可用性和事件循環的其他階段,例如timers階段:

check 階段

setImmediate(callback[, ...args])

當您從 I/O 回調調用 setImmediate 函數時,如下例所示,事件循環將保證它將在事件循環的同一迭代中的檢查階段運行:

const fs = require("fs");

let counter = 0;

fs.readFile("path/to/file"{ encoding: "utf8" }() ={
  console.log(`Inside I/O, counter = ${++counter}`);

  setImmediate(() ={
    console.log(`setImmediate 1 from I/O callback, counter = ${++counter}`);
  });

  setTimeout(() ={
    console.log(`setTimeout from I/O callback, counter = ${++counter}`);
  }, 0);

  setImmediate(() ={
    console.log(`setImmediate 2 from I/O callback, counter = ${++counter}`);
  });
});

check階段的回調 setImmediate產生的任何微任務和nextTick將分別添加到微任務隊列和nextTick隊列中,並像其他階段一樣立即耗盡。

close 回調

此 close 階段是 Node.js 執行事件回調 close 事件,並結束給定事件循環迭代的地方。當socket關閉時,事件循環將在此階段處理 close 事件。如果在此階段生成nextTick和微任務,則它們將像在事件循環的其他階段一樣進行處理。

值得強調的是,您可以通過調用該方法 process.exit 在任何階段終止事件循環。Node.js 進程將退出,事件循環將忽略掛起的異步操作。

實踐中的 Node.js 事件循環

如上所述,瞭解 Node.js 事件循環對於編寫高性能、非阻塞異步代碼非常重要。在 Node.js 中使用異步 API 將並行運行您的代碼,但您的 JavaScript 回調將始終在單個線程上運行。

因此,在執行 JavaScript 回調時,可能會無意中阻塞事件循環。由於 Node.js 是一種服務器端語言,因此阻塞事件循環會使服務器運行緩慢且無響應,從而降低吞吐量。

在下面的示例中,我故意運行一個 while 循環大約一分鐘來模擬長時間運行的操作。當您命中/blocking 端點時,事件循環將在事件循環的輪詢階段執行app.get 回調:

const longRunningOperation = (duration = 1 * 60 * 1000) ={
  const start = Date.now();
  while (Date.now() - start < duration) {}
};

app.get("/blocking"(req, res) ={
  longRunningOperation();
  res.send({ message: "blocking route" });
});

app.get("/non-blocking"(req, res) ={
  res.send({ message: "non blocking route" });
});

由於回調正在執行耗時的操作,因此事件循環在任務運行的持續時間內被阻塞。對 /non-blocking 路由的任何請求也將等待事件循環首次解鎖。因此,您的應用程序將變得無響應。來自前端的請求將變得緩慢並最終超時。若要執行此類 CPU 密集型操作,可以利用工作線程。

同樣,不要對服務器端的以下模塊使用同步 API,因爲它們可能會阻塞事件循環:

關於 Node.js 的常見問題

Node.js 是多線程的嗎?

如上所述,Node.js 在單個線程中運行 JavaScript 代碼。但是,它具有用於併發的工作線程。確切地說,除了主線程之外,Node 默認還有一個由四個線程組成的線程池。

Libuv 是賦予 Node.js其異步、非阻塞 I/O 功能的負責管理線程池的底層庫。Node.js 使您能夠使用其他線程進行計算成本高昂且持久的操作,以避免阻塞事件循環。

Promise 是否在單獨的線程上運行?

Node.js 種的Promise不會在單獨的線程上運行。.then.catch.finally 回調將添加到微任務隊列中。如上所述,微任務隊列中的回調在事件循環的所有主要階段都在同一線程上執行。

爲什麼事件循環在 Node.js 中很重要?

事件循環編排 Node 的異步和非阻塞功能。它負責監視客戶端請求並響應服務器端的請求。

如果 JavaScript 回調阻塞了事件循環,您的服務器將變得緩慢且對客戶端請求無響應。如果沒有事件循環,Node.js 就不會像現在這樣強大,而 Node.js 服務器的速度會非常慢。

異步程序在 Node.js 中是如何工作的?

Node.js 具有多個內置的同步和異步 API。同步 API 會阻止 JavaScript 代碼的執行,直到操作完成。

在下面的示例中,我們用於 fs.readFileSync 讀取文件內容。 fs.readFileSync 是同步的。因此,它將阻止其餘 JavaScript 代碼的執行,直到文件讀取過程完成,然後再移動到下一行代碼:

const fs = require("fs");
const path = require("path");

console.log("At the top");

try {
  const data = fs.readFileSync(path.join(__dirname, "notes.txt"){
    encoding: "utf8",
  });
  console.log(data);
} catch (error) {
  console.error(error);
}

console.log("At the bottom");

另一方面,非阻塞異步 API 通過將操作卸載到運行 Node.js 的線程池或本機系統來並行執行操作。操作完成後,事件循環將調度並執行 JavaScript 回調。

例如,fs模塊的異步形式使用線程池來寫入或讀取文件內容。當文件操作的內容準備好進行處理時,事件循環會在輪詢階段執行 JavaScript 回調。

在下面的示例中, fs.readFile 是異步和非阻塞的。事件循環將在文件讀取操作完成時執行傳遞給它的回調。代碼的其餘部分將運行,而無需等待文件操作完成:

const fs = require("fs");

console.log("At the top");

fs.readFile("path/to/file"{ encoding: "utf8" }(err, data) ={
  if (err) {
    console.error("error", err);
    return;
  }

  console.log("data", data);
});

console.log("At the bottom");

微任務何時在 Node.js 中執行?

微任務在事件循環的所有主要階段的操作之間執行。事件循環的每個主要階段都執行一個 JavaScript 回調隊列。在階段隊列中連續執行JavaScript回調之間,有一個微任務檢查點,其中微任務隊列被排空。

如何退出 Node.js 事件循環?

只要有掛起的事件需要處理,Node.js 事件循環就會運行。如果沒有任何掛起的工作,則事件循環在發出 exit 事件後退出,並返回退出偵聽器回調。

還可以通過使用 process.exit 該方法顯式退出事件循環。調用 process.exit 將立即退出正在運行的 Node.js 進程。事件循環中的任何掛起或計劃事件都將被放棄:

process.on("exit"(code) ={
  console.log(`Exiting with exit code: ${code}`);
});

process.exit(1);

您可以收聽 exit 事件。但是,偵聽器函數必須是同步的,因爲 Node.js 進程將在偵聽器函數返回後立即退出。

總結

Node.js 運行時環境具有用於編寫非阻塞代碼的 API。但是,由於所有 JavaScript 代碼都在單個線程上執行,因此可能會無意中阻塞事件循環。深入瞭解事件循環有助於您編寫可靠、安全且高性能的代碼,並有效地調試性能問題。

事件循環大約有六個主要階段。這六個階段是計時器、掛起、idleprepare、輪詢、checkclose。每個階段都有一個事件隊列,事件循環會處理這些事件隊列,直到它爲空或達到與系統相關的硬限制。

執行回調時,事件循環被阻塞。因此,請確保異步回調不會長時間阻塞事件循環,否則服務器將變得緩慢且對客戶端請求無響應。您可以使用線程池執行長時間運行或 CPU 密集型任務。

一川說

覺得文章不錯的讀者,不妨點個關注,收藏起來上班摸魚的時候品嚐。

歡迎關注筆者公衆號「宇宙一碼平川」,助你技術路上一碼平川。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Q1aZNZ4ojlTGRezKinVfLQ