你已經(jīng)使用 Node.js 一段時(shí)間了,構(gòu)建了一些應(yīng)用程序,嘗試了不同的模塊,甚至對(duì)異步編程感到很舒適。但是有些事情一直在困擾著你——事件循環(huán)(Event Loop)。
如果你像我一樣,花費(fèi)了無(wú)數(shù)個(gè)小時(shí)閱讀文檔和觀看視頻,試圖理解事件循環(huán)。但即使作為一個(gè)經(jīng)驗(yàn)豐富的開(kāi)發(fā)者,在完全理解它如何工作方面也可能會(huì)遇到困難。這就是為什么我準(zhǔn)備了這份視覺(jué)指南,幫助您充分理解 Node.js 事件循環(huán)。請(qǐng)坐下來(lái),拿杯咖啡,讓我們深入探索 Node.js 事件循環(huán)的世界吧。
我們將從 JavaScript 中異步編程的復(fù)習(xí)開(kāi)始。雖然 JavaScript 在 Web、移動(dòng)和桌面應(yīng)用程序中都有使用,但重要的是要記住,本質(zhì)上,JavaScript 是一種同步、阻塞、單線(xiàn)程的語(yǔ)言。讓我們通過(guò)一個(gè)簡(jiǎn)短的代碼片段來(lái)理解這句話(huà)。
// index.jsfunction A() { console.log("A");}function B() { console.log("B");}A()B()// Logs A and then B
如果我們有兩個(gè)將消息記錄到控制臺(tái)的函數(shù),那么代碼會(huì)自上而下執(zhí)行,每次只執(zhí)行一行。在上述代碼片段中,我們看到 A 在 B 之前被記錄。
JavaScript 由于其同步性質(zhì)而被阻塞。無(wú)論前一個(gè)進(jìn)程需要多長(zhǎng)時(shí)間,后續(xù)進(jìn)程都不會(huì)啟動(dòng),直到前者完成為止。在代碼片段中,如果函數(shù) A 必須執(zhí)行大量代碼塊,則 JavaScript 必須在沒(méi)有轉(zhuǎn)移到函數(shù) B 的情況下完成該操作。即便這塊代碼需要耗時(shí) 10 秒甚至 1 分鐘。
你可能已經(jīng)在瀏覽器中遇到過(guò)這種情況。當(dāng) Web 應(yīng)用程序在瀏覽器中運(yùn)行并且執(zhí)行一些密集的代碼塊而不返回控制權(quán)給瀏覽器時(shí),瀏覽器可能會(huì)出現(xiàn)卡死的情況,這就是所謂的阻塞。瀏覽器被阻止繼續(xù)處理用戶(hù)輸入和執(zhí)行其他任務(wù),直到 Web 應(yīng)用程序?qū)⑻幚砥骺刂茩?quán)歸還給瀏覽器。
線(xiàn)程就是你的 JavaScript 程序可以用來(lái)運(yùn)行任務(wù)的進(jìn)程(process)。每個(gè)線(xiàn)程一次只能執(zhí)行一個(gè)任務(wù)。與其他支持多線(xiàn)程并且可以同時(shí)運(yùn)行多個(gè)任務(wù)的語(yǔ)言不同,JavaScript 只有一個(gè)稱(chēng)為主線(xiàn)程的線(xiàn)程執(zhí)行代碼。
如你所想,這種 JavaScript 模型會(huì)帶來(lái)問(wèn)題,因?yàn)槲覀儽仨毜却龜?shù)據(jù)被獲取后才能繼續(xù)執(zhí)行代碼。這個(gè)等待可能需要幾秒鐘,在此期間我們無(wú)法運(yùn)行任何其他代碼。如果 JavaScript 在不等待的情況下繼續(xù)處理,就會(huì)出錯(cuò)。我們需要在 JavaScript 中實(shí)現(xiàn)異步行為。我們進(jìn)到 Node.js 看一下。
Node.js 運(yùn)行時(shí)是一個(gè)環(huán)境,你可以在不使用瀏覽器的情況下使用和運(yùn)行 JavaScript 程序。核心——Node 運(yùn)行時(shí),由三個(gè)主要組件組成。
雖然所有部分都很重要,但異步編程在 Node.js 中的關(guān)鍵組件是 libuv。
Libuv[2] 是一個(gè)跨平臺(tái)的開(kāi)源庫(kù),用 C 語(yǔ)言編寫(xiě)。在 Node.js 運(yùn)行時(shí)中,它的作用是提供處理異步操作的支持。我們來(lái)看一下它是如何工作的。
圖片
讓我們來(lái)概括一下代碼在 Node 運(yùn)行時(shí)中的執(zhí)行方式。在執(zhí)行代碼時(shí),位于圖片左側(cè)的 V8 引擎負(fù)責(zé) JavaScript 代碼的執(zhí)行。該引擎包含一個(gè)內(nèi)存堆(Memory heap)和一個(gè)調(diào)用棧(Call stack)。
每當(dāng)聲明變量或函數(shù)時(shí),都會(huì)在堆上分配內(nèi)存。執(zhí)行代碼時(shí),函數(shù)就會(huì)被推入調(diào)用棧中。當(dāng)函數(shù)返回時(shí),它就從調(diào)用棧中彈出了。這是對(duì)棧數(shù)據(jù)結(jié)構(gòu)的簡(jiǎn)單實(shí)現(xiàn),最后添加的項(xiàng)是第一個(gè)被移除。在圖片右側(cè),是負(fù)責(zé)處理異步方法的 libuv。
每當(dāng)我們執(zhí)行異步方法時(shí),libuv 接管任務(wù)的執(zhí)行。然后使用操作系統(tǒng)本地異步機(jī)制運(yùn)行任務(wù)。如果本地機(jī)制不可用或不足,則利用其線(xiàn)程池來(lái)運(yùn)行任務(wù),并確保主線(xiàn)程不被阻塞。
首先,讓我們來(lái)看一下同步代碼執(zhí)行。以下代碼由三個(gè)控制臺(tái)日志語(yǔ)句組成,依次記錄“First”,“Second”和“Third”。我們按照運(yùn)行時(shí)執(zhí)行順序來(lái)查看代碼。
// index.jsconsole.log("First");console.log("Second");console.log("Third");
以下是 Node 運(yùn)行時(shí)執(zhí)行同步代碼的可視化展示。
圖片
圖片
執(zhí)行的主線(xiàn)程始終從全局作用域開(kāi)始。全局函數(shù)(如果我們可以這樣稱(chēng)呼它)被推入堆棧中。然后,在第 1 行,我們有一個(gè)控制臺(tái)日志語(yǔ)句。這個(gè)函數(shù)被推入堆棧中。假設(shè)這個(gè)發(fā)生在 1 毫秒時(shí),“First” 被記錄在控制臺(tái)上。然后,這個(gè)函數(shù)從堆棧中彈出。
執(zhí)行到第 2 行時(shí)。假設(shè)到第 2 毫秒了,log 函數(shù)再次被推入堆棧中。“Second”被記錄在控制臺(tái)上,并彈出該函數(shù)。
最后,執(zhí)行到第 3 行了。第 3 毫秒時(shí),log 函數(shù)被推入堆棧,“Third”將記錄在控制臺(tái)上,并彈出該函數(shù)。此時(shí)已經(jīng)沒(méi)有代碼要執(zhí)行,全局也被彈出。
接下來(lái),讓我們看一下異步代碼執(zhí)行。有以下代碼片段:包含三個(gè)日志語(yǔ)句,但這次第二個(gè)日志語(yǔ)句傳遞給了fs.readFile() 作為回調(diào)函數(shù)。
圖片
執(zhí)行的主線(xiàn)程始終從全局作用域開(kāi)始。全局函數(shù)被推入堆棧。然后執(zhí)行到第 1 行,在第 1 毫秒時(shí),“First”被記錄在控制臺(tái)中,并彈出該函數(shù)。然后執(zhí)行移動(dòng)到第 2 行,在第 2毫秒時(shí),readFile 方法被推入堆棧。由于 readFile 是異步操作,因此它會(huì)轉(zhuǎn)移(off-loaded)到 libuv。
JavaScript 從調(diào)用堆棧中彈出了 readFile 方法,因?yàn)榫偷?2 行的執(zhí)行而言,它的工作已經(jīng)完成了。在后臺(tái),libuv 開(kāi)始在單獨(dú)的線(xiàn)程上讀取文件內(nèi)容。在第 3 毫秒時(shí),JavaScript 繼續(xù)進(jìn)行到第 5 行,將 log 函數(shù)推入堆棧,“Third”被記錄到控制臺(tái)中,并將該函數(shù)彈出堆棧。
大約在第 4 毫秒左右,假設(shè)文件讀取任務(wù)已經(jīng)完成,則相關(guān)回調(diào)函數(shù)現(xiàn)在會(huì)在調(diào)用棧上執(zhí)行, 在回調(diào)函數(shù)內(nèi)部遇到 log 函數(shù)。
log 函數(shù)推入到到調(diào)用棧,“Second”被記錄到控制臺(tái)并彈出 log 函數(shù) 。由于回調(diào)函數(shù)中沒(méi)有更多要執(zhí)行的語(yǔ)句,因此也被彈出 。沒(méi)有更多代碼可運(yùn)行了 ,所以全局函數(shù)也從堆棧中刪除 。
控制臺(tái)輸出“First”,“Third”,然后是“Second”。
很明顯,libuv 用于處理 Node.js 中的異步操作。對(duì)于像處理網(wǎng)絡(luò)請(qǐng)求這樣的異步操作,libuv 依賴(lài)于操作系統(tǒng)原生機(jī)制。對(duì)于沒(méi)有本地 OS 支持的異步讀取文件的操作,libuv 則依賴(lài)其線(xiàn)程池以確保主線(xiàn)程不被阻塞。然而,這也引發(fā)了一些問(wèn)題。
所有這些問(wèn)題都可以通過(guò)理解 libuv 核心部分——事件循環(huán)來(lái)得到答案。
從技術(shù)上講,事件循環(huán)只是一個(gè) C 語(yǔ)言程序。但是在 Node.js 中,你可以將其視為一種設(shè)計(jì)模式,用于協(xié)調(diào)同步和異步代碼的執(zhí)行。
事件循環(huán)是一個(gè)循環(huán),只要你的 Node.js 應(yīng)用程序在運(yùn)行,它就一直運(yùn)行。每個(gè)循環(huán)中有六個(gè)不同的隊(duì)列,每個(gè)隊(duì)列都包含一個(gè)或多個(gè)需要最終在調(diào)用堆棧上執(zhí)行的回調(diào)函數(shù)。
圖片
最后,有兩個(gè)不同隊(duì)列組成微任務(wù)隊(duì)列(microtask queue)。
需要注意的是計(jì)時(shí)器、I/O、檢查和關(guān)閉隊(duì)列都屬于 libuv。然而,兩個(gè)微任務(wù)隊(duì)列并不屬于 libuv。盡管如此,它們?nèi)匀皇?Node 運(yùn)行時(shí)環(huán)境中扮演著重要角色,并且在執(zhí)行回調(diào)順序方面發(fā)揮著重要作用。說(shuō)到這里, 讓我們來(lái)理解一下事件循環(huán)是如何工作的。
圖中箭頭是一個(gè)提示,但可能還不太容易理解。讓我來(lái)解釋一下隊(duì)列的優(yōu)先級(jí)順序。首先要知道,所有用戶(hù)編寫(xiě)的同步 JavaScript 代碼都比異步代碼優(yōu)先級(jí)更高。這表示只有在調(diào)用堆棧為空時(shí),事件循環(huán)才會(huì)發(fā)揮作用。
在事件循環(huán)中,執(zhí)行順序遵循某些規(guī)則。需要掌握的規(guī)則還是有一些的,我們逐個(gè)的了解一下:
此時(shí),如果還有更多的回調(diào)需要處理,那么事件循環(huán)再運(yùn)行一次(譯注:事件循環(huán)在程序運(yùn)行期間一直在運(yùn)行,在當(dāng)前沒(méi)有可供處理的任務(wù)情況下,會(huì)處于等待狀態(tài),一旦有新任務(wù)就會(huì)執(zhí)行),并重復(fù)相同的步驟。另一方面,如果所有回調(diào)都已執(zhí)行并且沒(méi)有更多代碼要處理(譯注:也就是程序執(zhí)行結(jié)束),則事件循環(huán)退出。
這就是 libuv 事件循環(huán)在 Node.js 中執(zhí)行異步代碼的作用。有了這些規(guī)則,我們可以重新審視之前提出的問(wèn)題。
當(dāng)一個(gè)異步任務(wù)在 libuv 中完成時(shí),什么時(shí)候 Node 會(huì)在調(diào)用棧上運(yùn)行相關(guān)聯(lián)的回調(diào)函數(shù)?
答案:只有當(dāng)調(diào)用棧為空時(shí)才執(zhí)行回調(diào)函數(shù)。
Node 是否會(huì)等待調(diào)用棧為空后再運(yùn)行回調(diào)函數(shù)?還是打斷正常執(zhí)行流來(lái)運(yùn)行回調(diào)函數(shù)?
答案:運(yùn)行回調(diào)函數(shù)時(shí)不會(huì)打斷正常執(zhí)行流。
像 setTimeout 和 setInterval 這類(lèi)延遲執(zhí)行回調(diào)函數(shù)的方法又是何時(shí)執(zhí)行回調(diào)函數(shù)呢?
答案:*setTimeout 和 setInterval 的所有回調(diào)函數(shù)中第一優(yōu)先級(jí)執(zhí)行的(不考慮微任務(wù)隊(duì)列)。*
如果兩個(gè)異步任務(wù)(例如 setTimeout 和 readFile)同時(shí)完成,Node 如何決定那個(gè)回調(diào)函數(shù)先在調(diào)用棧中執(zhí)行?其中一個(gè)會(huì)比另一個(gè)有更高優(yōu)先權(quán)嗎?
答案:在同時(shí)完成的情況下,計(jì)時(shí)器回調(diào)會(huì)先于 I/O 回調(diào)執(zhí)行。
到此為止我們學(xué)了很多,但我希望大家可以把下面這張圖片展現(xiàn)的執(zhí)行順序銘記于心,因?yàn)樗暾谋憩F(xiàn)了 Node.js 在幕后是如何執(zhí)行異步代碼的。
圖片
事件循環(huán)是 Node.js 的基本組成部分,通過(guò)確保主線(xiàn)程不被阻塞來(lái)實(shí)現(xiàn)異步編程。了解事件循環(huán)的工作原理可能具有挑戰(zhàn)性,但對(duì)于構(gòu)建高效應(yīng)用程序至關(guān)重要。
這個(gè)視覺(jué)指南涵蓋了 JavaScript 中異步編程、Node.js 運(yùn)行時(shí)和負(fù)責(zé)處理異步操作的 libuv 的基礎(chǔ)知識(shí)。有了這些知識(shí),你可以建立一個(gè)強(qiáng)大的事件循環(huán)模型,在編寫(xiě)利用 Node.js 異步特性的代碼時(shí)受益。
[1]A Complete Visual Guide to Understanding the Node.js Event Loop:https://www.builder.io/blog/visual-guide-to-nodejs-event-loop
[2]Libuv:https://libuv.org/
本文鏈接:http://www.www897cc.com/showinfo-26-57911-0.html理解 Node.js 中的事件循環(huán)
聲明:本網(wǎng)頁(yè)內(nèi)容旨在傳播知識(shí),若有侵權(quán)等問(wèn)題請(qǐng)及時(shí)與本網(wǎng)聯(lián)系,我們將在第一時(shí)間刪除處理。郵件:2376512515@qq.com
上一篇: 詳解SpringMVC底層原理