首頁  >  文章  >  web前端  >  一起聊聊Node中的事件循環

一起聊聊Node中的事件循環

青灯夜游
青灯夜游轉載
2023-04-11 19:08:211568瀏覽

事件循環是 Node.js 的基本組成部分,透過確保主執行緒不被阻塞來實現非同步編程,了解事件循環對建立高效應用程式至關重要。以下這篇文章就來帶大家深入了解Node中的事件循環 ,希望對大家有幫助!

一起聊聊Node中的事件循環

你已經使用 Node.js 一段時間了,建立了一些應用程序,嘗試了不同的模組,甚至對非同步程式設計感到很舒適。但有些事情一直困擾著你──事件循環(Event Loop)。

如果你像我一樣,花了無數個小時閱讀文檔和觀看視頻,試圖理解事件循環。但即使作為一個經驗豐富的開發者,在完全理解它如何運作方面也可能會遇到困難。這就是為什麼我準備了這份視覺指南,幫助您充分理解 Node.js 事件循環。請坐下來,拿杯咖啡,讓我們深入探索 Node.js 事件循環的世界。 【相關教學推薦:nodejs影片教學程式設計教學

#JavaScript 中的非同步程式設計

我們將從JavaScript 中非同步程式設計的複習開始。雖然 JavaScript 在 Web、行動和桌面應用程式中都有使用,但重要的是要記住,本質上,JavaScript 是一種同步、阻塞、單執行緒的語言。讓我們透過一個簡短的程式碼片段來理解這句話。

// index.js

function A() {
  console.log("A");
}

function B() {
  console.log("B");
}

A()
B()

// Logs A and then B

JavaScript 是同步的

如果我們有兩個將訊息記錄到控制台的函數,那麼程式碼會自上而下執行,每次只執行一行。在上述程式碼片段中,我們看到 A 在 B 之前被記錄。

JavaScript 是阻塞的

JavaScript 由於其同步性質而被封鎖。無論前一個進程需要多長時間,後續進程都不會啟動,直到前者完成為止。在程式碼片段中,如果函數 A 必須執行大量程式碼區塊,則 JavaScript 必須在沒有轉移到函數 B 的情況下完成該操作。即便這塊程式碼需要耗時 10 秒甚至 1 分鐘。

你可能已經在瀏覽器中遇到過這種情況。當 Web 應用程式在瀏覽器中運行並且執行一些密集的程式碼區塊而不返回控制權給瀏覽器時,瀏覽器可能會出現卡死的情況,這就是所謂的阻塞。瀏覽器被阻止繼續處理使用者輸入和執行其他任務,直到 Web 應用程式將處理器控制權歸還給瀏覽器。

JavaScript 是單執行緒的

執行緒就是你的 JavaScript 程式可以用來執行任務的進程(process)。每個執行緒一次只能執行一個任務。與其他支援多執行緒並且可以同時執行多個任務的語言不同,JavaScript 只有一個稱為主執行緒的執行緒執行程式碼。

等待 JavaScript

如你所想,這個 JavaScript 模型會帶來問題,因為我們必須等待資料被取得後才能繼續執行程式碼。這個等待可能需要幾秒鐘,在此期間我們無法運行任何其他程式碼。如果 JavaScript 在不等待的情況下繼續處理,就會出錯。我們需要在 JavaScript 中實作異步行為。我們進到 Node.js 看一下。

Node.js 運行時

一起聊聊Node中的事件循環

#Node.js 運行時是一個環境,​​你可以在不使用瀏覽器的情況下使用並執行JavaScript 程式。核心-Node 執行時,由三個主要元件組成。

  • 外部相依性 —— 例如 V8、libuv、crypto 等——是 Node.js 必要的功能
  • C 特性提供了檔案系統存取和網路等功能。
  • JavaScript 函式庫提供了函數和工具,方便使用 JavaScript 程式碼呼叫 C 特性。

雖然所有部分都很重要,但非同步程式設計在 Node.js 中的關鍵元件是 libuv。

Libuv

Libuv 是一個跨平台的開源函式庫,用 C 語言寫。在 Node.js 運行時中,它的作用是提供處理非同步操作的支援。讓我們來看看它是如何運作的。

Node.js 運行時中的程式碼執行

一起聊聊Node中的事件循環

#

让我们来概括一下代码在 Node 运行时中的执行方式。在执行代码时,位于图片左侧的 V8 引擎负责 JavaScript 代码的执行。该引擎包含一个内存堆(Memory heap)和一个调用栈(Call stack)。

每当声明变量或函数时,都会在堆上分配内存。执行代码时,函数就会被推入调用栈中。当函数返回时,它就从调用栈中弹出了。这是对栈数据结构的简单实现,最后添加的项是第一个被移除。在图片右侧,是负责处理异步方法的 libuv。

每当我们执行异步方法时,libuv 接管任务的执行。然后使用操作系统本地异步机制运行任务。如果本地机制不可用或不足,则利用其线程池来运行任务,并确保主线程不被阻塞。

同步代码执行

首先,让我们来看一下同步代码执行。以下代码由三个控制台日志语句组成,依次记录“First”,“Second”和“Third”。我们按照运行时执行顺序来查看代码。

// index.js
console.log("First");
console.log("Second");
console.log("Third");

以下是 Node 运行时执行同步代码的可视化展示。

一起聊聊Node中的事件循環

执行的主线程始终从全局作用域开始。全局函数(如果我们可以这样称呼它)被推入堆栈中。然后,在第 1 行,我们有一个控制台日志语句。这个函数被推入堆栈中。假设这个发生在 1 毫秒时,“First” 被记录在控制台上。然后,这个函数从堆栈中弹出。

执行到第 2 行时。假设到第 2 毫秒了,log 函数再次被推入堆栈中。“Second”被记录在控制台上,并弹出该函数。

最后,执行到第 3 行了。第 3 毫秒时,log 函数被推入堆栈,“Third”将记录在控制台上,并弹出该函数。此时已经没有代码要执行,全局也被弹出。

异步代码执行

接下来,让我们看一下异步代码执行。有以下代码片段:包含三个日志语句,但这次第二个日志语句传递给了fs.readFile() 作为回调函数。

一起聊聊Node中的事件循環

执行的主线程始终从全局作用域开始。全局函数被推入堆栈。然后执行到第 1 行,在第 1 毫秒时,“First”被记录在控制台中,并弹出该函数。然后执行移动到第 2 行,在第 2毫秒时,readFile 方法被推入堆栈。由于 readFile 是异步操作,因此它会转移(off-loaded)到 libuv。

JavaScript 从调用堆栈中弹出了 readFile 方法,因为就第 2 行的执行而言,它的工作已经完成了。在后台,libuv 开始在单独的线程上读取文件内容。在第 3 毫秒时,JavaScript 继续进行到第 5 行,将 log 函数推入堆栈,“Third”被记录到控制台中,并将该函数弹出堆栈。

大约在第 4 毫秒左右,假设文件读取任务已经完成,则相关回调函数现在会在调用栈上执行, 在回调函数内部遇到 log 函数。

log 函数推入到到调用栈,“Second”被记录到控制台并弹出 log 函数 。由于回调函数中没有更多要执行的语句,因此也被弹出 。没有更多代码可运行了 ,所以全局函数也从堆栈中删除 。

控制台输出“First”,“Third”,然后是“Second”。

Libuv 和异步操作

很明显,libuv 用于处理 Node.js 中的异步操作。对于像处理网络请求这样的异步操作,libuv 依赖于操作系统原生机制。对于没有本地 OS 支持的异步读取文件的操作,libuv 则依赖其线程池以确保主线程不被阻塞。然而,这也引发了一些问题。

  • 当一个异步任务在 libuv 中完成时,什么时候 Node 会在调用栈上运行相关联的回调函数?
  • Node 是否会等待调用栈为空后再运行回调函数?还是打断正常执行流来运行回调函数?
  • setTimeoutsetInterval 这类延迟执行回调函数的方法又是何时执行回调函数呢?
  • 如果 setTimeoutreadFile 这类异步任务同时完成,Node 如何决定哪个回调函数先在调用栈上运行?其中一个会有更多的优先级吗?

所有这些问题都可以通过理解 libuv 核心部分——事件循环来得到答案。

什麼是事件循環?

從技術上講,事件循環只是一個 C 語言程式。但在 Node.js 中,你可以將其視為一種設計模式,用於協調同步和非同步程式碼的執行。

視覺化事件循環

事件循環是一個循環,只要你的 Node.js 應用程式在運行,它就一直運行。每個循環中有六個不同的佇列,每個佇列都包含一個或多個需要最終在呼叫堆疊上執行的回調函數。

一起聊聊Node中的事件循環

  • 首先,有一個計時器佇列(timer queue。技術上叫最小堆(min-heap)),它保存與setTimeoutsetInterval 相關的回呼函數。
  • 其次,有一個I/O 佇列(I/O queue),其中包含與所有非同步方法相關的回呼函數,例如fshttp 模組中提供的相關方法。
  • 第三個是檢查佇列(check queue),它保存與 setImmediate 函數相關的回調函數,這是特定於Node 的功能。
  • 第四個是關閉佇列(close queue),它保存與非同步任務關閉事件相關聯的回呼函數。

最後,有兩個不同佇列組成微任務佇列(microtask queue)。

  • nextTick 佇列保存了與 process.nextTick 函數關聯的回呼函數。
  • Promise 佇列則儲存了JavaScript 中本機 Promise 相關聯的回呼函數。

要注意的是計時器、I/O、檢查和關閉佇列都屬於 libuv。然而,兩個微任務隊列並不屬於 libuv。儘管如此,它們仍然是 Node 運行時環境中扮演著重要角色,並且在執行回調順序方面發揮重要作用。說到這裡, 讓我們來理解一下事件循環是如何運作的。

事件循環是如何運作的?

圖中箭頭是一個提示,但可能還不太容易理解。讓我來解釋一下佇列的優先順序。首先要知道,所有使用者編寫的同步 JavaScript 程式碼都比非同步程式碼優先權更高。這表示只有在呼叫堆疊為空時,事件循環才會發揮作用。

在事件循環中,執行順序遵循某些規則。需要掌握的規則還是有一些的,我們逐一的了解:

  1. 執行微任務佇列(microtask queue)中的所有回呼函數。首先是 nextTick 佇列中的任務,然後是 Promise 佇列中的任務。
  2. 執行計時器佇列(timer queue)內的所有回呼函數。
  3. 如果微任務佇列中存在回呼函數,則在計時器佇列內每執行完一次回呼函數之後執行微任務佇列中的所有回呼函數。首先是 nextTick 佇列中的任務,然後是 Promise 佇列中的任務。
  4. 執行 I/O 佇列(I/O queue)內的所有回呼函數。
  5. 如果微任務佇列中存在回呼函數,請依照先 nextTick 佇列後 Promise 佇列的順序依序執行微任務佇列中的所有回呼函數。
  6. 執行檢查佇列(check queue)內的所有回呼函數。
  7. 如果微任務佇列中存在回呼函數,則在檢查佇列內每個回呼之後執行微任務佇列中的所有回呼函數 。首先是 nextTick 佇列中的任務,然後是 Promise 佇列中的任務。
  8. 執行關閉佇列(close queue)內的所有回呼函數。
  9. 在同一迴圈的最後,再執行一次微任務佇列。首先是 nextTick 佇列中的任務,然後是 Promise 佇列中的任務。

此時,如果還有更多的回呼需要處理,那麼事件循環再運行一次(譯註:事件循環在程式運行期間一直在運行,在當前沒有可供處理的任務情況下,會處於等待狀態,一旦新任務就會執行),並重複相同的步驟。另一方面,如果所有回調都已執行且沒有更多程式碼要處理(譯註:也就是程式執行結束),則事件循環退出。

這就是 libuv 事件循環在 Node.js 中執行非同步程式碼的作用。有了這些規則,我們可以重新檢視先前提出的問題。


當一個非同步任務在 libuv 中完成時,什麼時候 Node 會在呼叫堆疊上執行相關聯的回呼函數?

答案:只有當呼叫堆疊為空時才執行回呼函數。

Node 是否會等待呼叫堆疊為空後再執行回呼函數?還是打斷正常執行流來運行回呼函數?

答案:執行回呼函數時不會打斷正常執行流。

像是 setTimeoutsetInterval 這類延遲執行回呼函數的方法又是何時執行回呼函數呢?

答案:setTimeoutsetInterval 的所有回呼函數中第一優先權執行的(不考慮微任務佇列)。

如果兩個非同步任務(例如setTimeoutreadFile)同時完成,Node 如何決定那個回呼函數先在呼叫堆疊中執行?其中一個會比另一個有更高優先權嗎?

答案:在同時完成的情況下,計時器回呼會先於 I/O 回呼執行。


到此為止我們學了很多,但我希望大家可以把下面這張圖片展現的執行順序銘記於心,因為它完整的表現了Node.js 在幕後是如何執行異步程式碼的。

一起聊聊Node中的事件循環

但是,你可能會問:「驗證這個視覺化的程式碼在哪裡?」。好吧,事件循環中的每個隊列都有執行上的細微差別,因此我們最好一個個來講比較好。本文是 Node.js 事件循環系列文章中的第一篇。請務必查看文末鏈接,以便了解每個隊列中的運行細節,即便你現在在腦海中印像很深了,到具體場景的時候可能還進到一些陷阱。

結論

這個視覺指南涵蓋了 JavaScript 中非同步程式設計、Node.js 執行時期和負責處理非同步操作的 libuv 的基礎知識。有了這些知識,你可以建立一個強大的事件循環模型,在編寫利用 Node.js 非同步特性的程式碼時受益。

更多node相關知識,請造訪:nodejs 教學

以上是一起聊聊Node中的事件循環的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:juejin.cn。如有侵權,請聯絡admin@php.cn刪除