首頁 >web前端 >js教程 >Node.js事件循環:開發人員的概念和代碼指南

Node.js事件循環:開發人員的概念和代碼指南

Christopher Nolan
Christopher Nolan原創
2025-02-12 08:36:12575瀏覽

Node.js 的異步編程:深入理解事件循環

The Node.js Event Loop: A Developer's Guide to Concepts & Code

異步編程在任何編程語言中都極具挑戰性。並發、並行和死鎖等概念讓即使是最資深的工程師也感到棘手。異步執行的代碼難以預測,出現bug時也難以追踪。然而,這個問題是不可避免的,因為現代計算擁有多核處理器。每個CPU內核都有其熱限制,單核性能提升已達到瓶頸。這促使開發者編寫高效的代碼,充分利用硬件資源。

JavaScript 是單線程的,但這是否限制了 Node.js 利用現代架構的能力呢?最大的挑戰之一是處理多線程的固有復雜性。創建新線程和管理線程間的上下文切換代價高昂。操作系統和程序員都需要付出大量努力才能提供一個處理眾多邊緣情況的解決方案。本文將闡述 Node.js 如何通過事件循環來解決這個難題,深入探討 Node.js 事件循環的各個方面並演示其工作原理。事件循環是 Node.js 的殺手級特性之一,因為它以一種全新的方式解決了這個棘手的問題。

關鍵要點

  • Node.js 事件循環是一個單線程、非阻塞和異步並發的循環,允許高效處理多個任務,而無需等待每個任務完成。這使得同時處理多個 Web 請求成為可能。
  • 事件循環是半無限的,這意味著如果調用棧或回調隊列為空,它可以退出。該循環負責輪詢操作系統以獲取來自傳入連接的回調。
  • 事件循環在多個階段運行:時間戳更新、循環活躍性檢查、定時器執行、待處理回調執行、空閑處理程序執行、準備setImmediate 回調執行的句柄、計算輪詢超時、阻塞I/O 、檢查句柄回調執行、關閉回調執行以及迭代結束。
  • Node.js 利用兩個主要部分:V8 JavaScript 引擎和 libuv。網絡 I/O、文件 I/O 和 DNS 查詢通過 libuv 進行。線程池中可用於這些任務的線程數量有限,可以通過 UV_THREADPOOL_SIZE 環境變量進行設置。
  • 在每個階段結束時,循環執行 process.nextTick() 回調,它不是事件循環的一部分,因為它在每個階段結束時運行。 setImmediate() 回調是整個事件循環的一部分,因此它並不像名稱暗示的那樣立即執行。一般建議使用 setImmediate()。

什麼是事件循環?

事件循環是一個單線程、非阻塞和異步並發的循環。對於沒有計算機科學學位的人來說,想像一下一個執行數據庫查找的 Web 請求。單線程一次只能執行一項操作。它不會等待數據庫響應,而是繼續處理隊列中的其他任務。在事件循環中,主循環展開調用棧,並且不等待回調。由於循環不會阻塞,因此它可以同時處理多個 Web 請求。多個請求可以同時排隊,使其具有並發性。循環不會等待一個請求的所有操作都完成,而是根據回調的出現順序進行處理,而不會阻塞。

循環本身是半無限的,這意味著如果調用棧或回調隊列為空,它可以退出循環。可以將調用棧視為同步代碼,例如 console.log,在循環輪詢更多工作之前展開。 Node.js 使用底層的 libuv 來輪詢操作系統以獲取來自傳入連接的回調。

您可能想知道,為什麼事件循環在單線程中執行?對於每個連接所需的數據而言,線程在內存中相對較重。線程是操作系統資源,需要啟動,這無法擴展到數千個活動連接。

通常情況下,多線程也會使情況復雜化。如果回調返回數據,它必須將上下文編組回正在執行的線程。線程間的上下文切換速度很慢,因為它必須同步當前狀態,例如調用棧或局部變量。事件循環在多個線程共享資源時可以避免bug,因為它單線程。單線程循環減少了線程安全邊緣情況,並且可以更快地進行上下文切換。這就是循環背後的真正天才之處。它在保持可擴展性的同時有效地利用了連接和線程。

理論足夠了;現在來看看代碼是什麼樣的。您可以隨意在 REPL 中進行操作或下載源代碼。

半無限循環

事件循環必須回答的最大問題是循環是否處於活動狀態。如果是,則確定在回調隊列上等待多長時間。在每次迭代中,循環展開調用棧,然後進行輪詢。

這是一個阻塞主循環的示例:

<code class="language-javascript">setTimeout(
  () => console.log('Hi from the callback queue'),
  5000); // 保持循环活动这么长时间

const stopTime = Date.now() + 2000;
while (Date.now() < stopTime) {}</code>

如果您運行此代碼,請注意循環被阻塞了兩秒鐘。但是,循環會保持活動狀態,直到回調在五秒鐘後執行。一旦主循環解除阻塞,輪詢機制就會確定它在回調上等待多長時間。當調用棧展開並且沒有剩餘回調時,此循環結束。

回調隊列

現在,當我阻塞主循環然後調度回調時會發生什麼?一旦循環被阻塞,它就不會將更多回調添加到隊列中:

<code class="language-javascript">const stopTime = Date.now() + 2000;
while (Date.now() < stopTime) {}
// 这需要 7 秒才能执行
setTimeout(() => console.log('Ran callback A'), 5000);</code>

這次循環保持活動狀態七秒鐘。事件循環在其簡單性方面是愚蠢的。它無法知道將來可能會排隊什麼。在實際系統中,傳入的回調會排隊並在主循環可以進行輪詢時執行。事件循環在解除阻塞時會順序地經歷幾個階段。因此,為了在關於循環的面試中脫穎而出,請避免使用“事件發射器”或“反應器模式”等花哨的術語。它是一個簡單的單線程循環,並發且非阻塞。

使用 async/await 的事件循環

為了避免阻塞主循環,一個想法是用 async/await 包裝同步 I/O:

<code class="language-javascript">const fs = require('fs');
const readFileSync = async (path) => await fs.readFileSync(path);

readFileSync('readme.md').then((data) => console.log(data));
console.log('The event loop continues without blocking...');</code>

await 之後出現的任何內容都來自回調隊列。代碼看起來像同步阻塞代碼,但它不會阻塞。請注意,async/await 使 readFileSync 成為可 then 的,這將其從主循環中移除。可以將 await 之後出現的任何內容視為通過回調進行的非阻塞操作。

完全披露:以上代碼僅用於演示目的。在實際代碼中,我建議使用 fs.readFile,它會觸發一個可以圍繞 Promise 包裝的回調。總體意圖仍然有效,因為這將阻塞 I/O 從主循環中移除。

更進一步

如果我告訴你事件循環不僅僅是調用棧和回調隊列呢?如果事件循環不僅僅是一個循環,而是多個循環呢?如果它可以在底層擁有多個線程呢?

現在,我想帶你深入 Node.js 內部。

事件循環階段

這些是事件循環階段:

The Node.js Event Loop: A Developer's Guide to Concepts & Code

圖片源:libuv 文檔

  1. 更新時間戳。事件循環在循環開始時緩存當前時間,以避免頻繁進行與時間相關的系統調用。這些系統調用是 libuv 的內部調用。
  2. 循環是否處於活動狀態?如果循環具有活動句柄、活動請求或正在關閉的句柄,則它處於活動狀態。如所示,隊列中的待處理回調使循環保持活動狀態。
  3. 執行到期的定時器。這是 setTimeout 或 setInterval 回調運行的地方。循環檢查緩存的now 以使到期的活動回調執行。
  4. 執行隊列中的待處理回調。如果之前的迭代延遲了任何回調,則這些回調會在此時運行。輪詢通常會立即運行 I/O 回調,但也有例外。此步驟處理來自上一次迭代的任何滯後回調。
  5. 執行空閑處理程序——主要是因為命名不當,因為這些處理程序在每次迭代中都會運行,並且是 libuv 的內部處理程序。
  6. 準備在循環迭代中執行 setImmediate 回調的句柄。這些句柄在循環阻塞 I/O 之前運行,並為這種回調類型準備隊列。
  7. 計算輪詢超時。循環必須知道它阻塞 I/O 的時間。這就是它如何計算超時的:
    • 如果循環即將退出,則超時為 0。
    • 如果沒有活動句柄或請求,則超時為 0。
    • 如果有任何空閒句柄,則超時為 0。
    • 如果隊列中有任何待處理的句柄,則超時為 0。
    • 如果有任何正在關閉的句柄,則超時為 0。
    • 如果以上都不是,則超時設置為最接近的定時器,如果沒有任何活動定時器,則為無限
  8. 循環使用上一個階段的持續時間阻塞 I/O。隊列中的與 I/O 相關的回調在此處執行。
  9. 執行檢查句柄回調。此階段是 setImmediate 運行的階段,它是準備句柄的對應階段。在 I/O 回調執行過程中排隊的任何 setImmediate 回調都會在此處運行。
  10. 執行關閉回調。這些是從已關閉連接中釋放的活動句柄。
  11. 迭代結束。

您可能想知道為什麼輪詢在應該是非阻塞的情況下會阻塞 I/O?只有當隊列中沒有待處理的回調並且調用棧為空時,循環才會阻塞。在 Node.js 中,最接近的定時器可以通過 setTimeout 設置,例如。如果設置為無限大,則循環將等待傳入連接以進行更多工作。這是一個半無限循環,因為當沒有剩餘工作並且存在活動連接時,輪詢會使循環保持活動狀態。

以下是此超時計算的 Unix 版本,以其全部 C 代碼形式:

<code class="language-javascript">setTimeout(
  () => console.log('Hi from the callback queue'),
  5000); // 保持循环活动这么长时间

const stopTime = Date.now() + 2000;
while (Date.now() < stopTime) {}</code>

您可能不太熟悉 C 語言,但這讀起來像英語,並且完全按照第七階段所述執行。

逐階段演示

為了用純 JavaScript 顯示每個階段:

<code class="language-javascript">const stopTime = Date.now() + 2000;
while (Date.now() < stopTime) {}
// 这需要 7 秒才能执行
setTimeout(() => console.log('Ran callback A'), 5000);</code>

因為文件 I/O 回調在階段四和階段九之前運行,所以預計 setImmediate() 會先觸發:

<code class="language-javascript">const fs = require('fs');
const readFileSync = async (path) => await fs.readFileSync(path);

readFileSync('readme.md').then((data) => console.log(data));
console.log('The event loop continues without blocking...');</code>

沒有 DNS 查詢的網絡 I/O 比文件 I/O 成本更低,因為它在主事件循環中執行。文件 I/O 通過線程池排隊。 DNS 查詢也使用線程池,因此這使得網絡 I/O 與文件 I/O 一樣昂貴。

線程池

Node.js 內部有兩個主要部分:V8 JavaScript 引擎和 libuv。文件 I/O、DNS 查詢和網絡 I/O 通過 libuv 進行。

這是整體架構:

The Node.js Event Loop: A Developer's Guide to Concepts & Code

圖片源:libuv 文檔

對於網絡 I/O,事件循環在主線程內進行輪詢。此線程不是線程安全的,因為它不會與另一個線程進行上下文切換。文件 I/O 和 DNS 查詢是特定於平台的,因此方法是在線程池中運行它們。一個想法是自己進行 DNS 查詢以避免進入線程池,如上面的代碼所示。例如,輸入 IP 地址而不是 localhost 會將查找從池中移除。線程池中可用的線程數量有限,可以通過 UV_THREADPOOL_SIZE 環境變量進行設置。默認線程池大小約為四個。

V8 在單獨的循環中執行,清空調用棧,然後將控制權返回給事件循環。 V8 可以使用多個線程進行其自身循環之外的垃圾回收。可以將 V8 視為一個引擎,它接收原始 JavaScript 並將其在硬件上運行。

對於普通程序員來說,JavaScript 保持單線程,因為沒有線程安全問題。 V8 和 libuv 內部會啟動它們自己單獨的線程以滿足它們自己的需求。

如果 Node.js 中存在吞吐量問題,請從主事件循環開始。檢查應用程序完成單個迭代需要多長時間。它不應超過一百毫秒。然後,檢查線程池飢餓以及可以從池中驅逐的內容。也可以通過環境變量增加池的大小。最後一步是在同步執行的 V8 中對 JavaScript 代碼進行微基準測試。

總結

事件循環繼續迭代每個階段,因為回調被排隊。但是,在每個階段內,都有方法可以排隊另一種類型的回調。

process.nextTick() 與 setImmediate()

在每個階段結束時,循環執行 process.nextTick() 回調。請注意,此回調類型不是事件循環的一部分,因為它在每個階段結束時運行。 setImmediate() 回調是整個事件循環的一部分,因此它並不像名稱暗示的那樣立即執行。由於 process.nextTick() 需要了解事件循環的內部機制,因此我通常建議使用 setImmediate()。

您可能需要 process.nextTick() 的幾個原因:

  1. 允許網絡 I/O 在循環繼續之前處理錯誤、清理或重試請求。
  2. 可能需要在調用棧展開後但在循環繼續之前運行回調。

例如,事件發射器希望在其自身構造函數中觸發事件。調用棧必須先展開才能調用事件。

<code class="language-javascript">setTimeout(
  () => console.log('Hi from the callback queue'),
  5000); // 保持循环活动这么长时间

const stopTime = Date.now() + 2000;
while (Date.now() < stopTime) {}</code>

允許調用棧展開可以防止諸如 RangeError: Maximum call stack size exceeded 之類的錯誤。一個需要注意的是確保 process.nextTick() 不會阻塞事件循環。在同一階段內遞歸回調調用可能會導致阻塞問題。

結論

事件循環在其終極複雜性中體現了簡單性。它解決了一個難題,例如異步性、線程安全性和並發性。它刪除了無用或不需要的部分,並以最有效的方式最大限度地提高了吞吐量。因此,Node.js 程序員可以減少追逐異步錯誤的時間,而將更多時間用於交付新功能。

關於 Node.js 事件循環的常見問題

什麼是 Node.js 事件循環? Node.js 事件循環是允許 Node.js 執行非阻塞異步操作的核心機制。它負責在單線程事件驅動環境中處理 I/O 操作、定時器和回調。

Node 事件循環是如何工作的?事件循環不斷檢查事件隊列中是否有待處理的事件或回調,並按添加順序執行它們。它在一個循環中運行,根據事件的可用性處理事件,這使得 Node.js 中的異步編程成為可能。

事件循環在 Node.js 應用程序中的作用是什麼?事件循環是 Node.js 的核心,它確保應用程序保持響應能力,並且可以處理許多同時連接,而無需多線程。

Node.js 事件循環的階段有哪些? Node.js 中的事件循環有幾個階段,包括定時器、待處理回調、空閒、輪詢、檢查和關閉。這些階段決定了事件的處理方式和順序。

事件循環處理的最常見事件類型有哪些?常見的事件包括 I/O 操作(例如,從文件讀取或發出網絡請求)、定時器(例如,setTimeout 和 setInterval)和回調函數(例如,來自異步操作的回調)。

Node 如何在事件循環中處理長時間運行的操作?長時間運行的 CPU 密集型操作可能會阻塞事件循環,應使用 child_process 或 worker_threads 模塊等模塊將其卸載到子進程或工作線程中。

調用棧和事件循環有什麼區別?調用棧是一個數據結構,用於跟踪當前執行上下文中的函數調用,而事件循環負責管理異步和非阻塞操作。它們協同工作,因為事件循環調度回調和 I/O 操作的執行,然後將它們推送到調用棧中。

事件循環中的“tick”是什麼? “tick”指的是事件循環的單個迭代。在每次 tick 中,事件循環都會檢查是否有待處理的事件,並執行任何準備運行的回調。 Ticks 是 Node.js 應用程序中的基本工作單元。

以上是Node.js事件循環:開發人員的概念和代碼指南的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn