您好,登錄后才能下訂單哦!
本文小編為大家詳細介紹“Node異步和事件循環實例分析”,內容詳細,步驟清晰,細節處理妥當,希望這篇“Node異步和事件循環實例分析”文章能幫助大家解決疑惑,下面跟著小編的思路慢慢深入,一起來學習新知識吧。
Node 為什么要使用異步來作為核心編程模型呢?
前面說過,Node 最初是為打造高性能的 Web 服務器而生,假設業務場景中有幾組互不相關的任務要完成,現代主流的解決方式有以下兩種:
單線程串行依次執行。
多線程并行完成。
單線程串行依次執行,是一種同步的編程模型,它雖然比較符合程序員按順序思考的思維方式,易寫出更順手的代碼,但由于是同步執行 I/O,同一時刻只能處理單個請求,會導致服務器響應速度較慢,無法在高并發的應用場景下適用,且由于是阻塞 I/O,CPU 會一直等待 I/O 完成,無法做其他事情,使 CPU 的處理能力得不到充分利用,最終導致效率的低下,
而多線程的編程模型也會因為編程中的狀態同步、死鎖等問題讓開發人員頭疼。盡管多線程在多核 CPU 上能夠有效提升 CPU 的利用率。
雖然單線程串行依次執行和多線程并行完成的編程模型有其自身的優勢,但是在性能、開發難度等方面也有不足之處。
除此之外,從響應客戶端請求的速度出發,如果客戶端同時獲取兩個資源,同步方式的響應速度會是兩個資源的響應速度之和,而異步方式的響應速度會是兩者中最大的一個,性能優勢相比同步十分明顯。隨著應用復雜度的增加,該場景會演變成同時響應 n 個請求,異步相比于同步的優勢將會凸顯出來。
綜上所述,Node 給出了它的答案:利用單線程,遠離多線程死鎖、狀態同步等問題;利用異步 I/O,讓單線程遠離阻塞,以更好地使用 CPU。這就是 Node 使用異步作為核心編程模型的原因。
此外,為了彌補單線程無法利用多核 CPU 的缺點,Node 也提供了類似瀏覽器中 Web Workers 的子進程,該子進程可以通過工作進程高效地利用 CPU。
聊完了為什么要使用異步,那要如何實現異步呢?
我們通常所說的異步操作總共有兩類:一是像文件 I/O、網絡 I/O 這類與 I/O 有關的操作;二是像 setTimeOut
、setInterval
這類與 I/O 無關的操作。很明顯我們所討論的異步是指與 I/O 有關的操作,即異步 I/O。
異步 I/O 的提出是期望 I/O 的調用不會阻塞后續程序的執行,將原有等待 I/O 完成的這段時間分配給其余需要的業務去執行。要達到這個目的,就需要用到非阻塞 I/O。
阻塞 I/O 是 CPU 在發起 I/O 調用后,會一直阻塞,等待 I/O 完成。知道了阻塞 I/O,非阻塞 I/O 就很好理解了,CPU 在發起 I/O 調用后會立即返回,而不是阻塞等待,在 I/O 完成之前,CPU 可以處理其他事務。顯然,相比于阻塞 I/O,非阻塞 I/O 多于性能的提升是很明顯的。
那么,既然使用了非阻塞 I/O,CPU 在發起 I/O 調用后可以立即返回,那它是如何知道 I/O 完成的呢?答案是輪詢。
為了及時獲取 I/O 調用的狀態,CPU 會不斷重復調用 I/O 操作來確認 I/O 是否已經完成,這種重復調用判斷操作是否完成的技術就叫做輪詢。
顯然,輪詢會讓 CPU 不斷重復地執行狀態判斷,是對 CPU 資源的浪費。并且,輪詢的間間隔很難控制,如果間隔太長,I/O 操作的完成得不到及時的響應,間接降低應用程序的響應速度;如果間隔太短,難免會讓 CPU 花在輪詢的耗時變長,降低 CPU 資源的利用率。
因此,輪詢雖然滿足了非阻塞 I/O 不會阻塞后續程序的執行的要求,但是對于應用程序而言,它仍然只能算是一種同步,因為應用程序仍然需要等待 I/O 完全返回,依舊花費了很多時間來等待。
我們所期望的完美的異步 I/O,應該是應用程序發起非阻塞調用,無須通過輪詢的方式不斷查詢 I/O 調用的狀態,而是可以直接處理下一個任務,在 I/O 完成后通過信號量或回調將數據傳遞給應用程序即可。
如何實現這種異步 I/O 呢?答案是線程池。
雖然本文一直提到,Node 是單線程執行的,但此處的單線程是指 JavaScript 代碼是執行在單線程上的,對于 I/O 操作這類與主業務邏輯無關的部分,通過運行在其他線程的方式實現,并不會影響或阻塞主線程的運行,反而可以提高主線程的執行效率,實現異步 I/O。
通過線程池,讓主線程僅進行 I/O 的調用,讓其他多個線程進行阻塞 I/O 或者非阻塞 I/O 加輪詢技術完成數據獲取,再通過線程之間的通信將 I/O 得到的數據進行傳遞,這就輕松實現了異步 I/O:
主線程進行 I/O 調用,而線程池進行 I/O 操作,完成數據的獲取,然后通過線程之間的通信將數據傳遞給主線程,即可完成一次 I/O 的調用,主線程再利用回調函數,將數據暴露給用戶,用戶再利用這些數據來完成業務邏輯層面的操作,這就是 Node 中一次完整的異步 I/O 流程。而對于用戶來說,不必在意底層這些繁瑣的實現細節,只需要調用 Node 封裝好的異步 API,并傳入處理業務邏輯的回調函數即可,如下所示:
const fs = require("fs"); fs.readFile('example.js', (data) => { // 進行業務邏輯的處理 });
Node 的異步底層實現機制在不同平臺下有所不同:Windows 下主要通過 IOCP 來向系統內核發送 I/O 調用和從內核獲取已完成的 I/O 操作,配以事件循環,以此完成異步 I/O 的過程;Linux 下通過 epoll 實現這個過程;FreeBSD下通過 kqueue 實現,Solaris 下通過 Event ports 實現。線程池在 Windows 下由內核(IOCP)直接提供,*nix
系列則由 libuv 自行實現。
由于 Windows 平臺和 *nix
平臺的差異,Node 提供了 libuv 作為抽象封裝層,使得所有平臺兼容性的判斷都由這一層來完成,保證上層的 Node 與下層的自定義線程池及 IOCP 之間各自獨立。Node 在編譯期間會判斷平臺條件,選擇性編譯 unix 目錄或是 win 目錄下的源文件到目標程序中:
以上就是 Node 對異步的實現。
(線程池的大小可以通過環境變量 UV_THREADPOOL_SIZE
設置,默認值為 4,用戶可結合實際情況來調整這個值的大小。)
那么問題來了,在得到線程池傳遞過來的數據后,主線程是如何、何時調用回調函數的呢?答案是事件循環。
既然使用回調函數來進行對 I/O 數據的處理,就必然涉及到何時、如何調用回調函數的問題。在實際開發中,往往會涉及到多個、多類異步 I/O 調用的場景,如何合理安排這些異步 I/O 回調的調用,確保異步回調的有序進行是一個難題,而且,除了異步 I/O 之外,還存在定時器這類非 I/O 的異步調用,這類 API 實時性強,優先級相應地更高,如何實現不同優先級回調地調度呢?
因此,必須存在一個調度機制,對不同優先級、不同類型的異步任務進行協調,確保這些任務在主線程上有條不紊地運行。與瀏覽器一樣,Node 選擇了事件循環來承擔這項重任。
Node 根據任務的種類和優先級將它們分為七類:Timers、Pending、Idle、Prepare、Poll、Check、Close。對于每類任務,都存在一個先進先出的任務隊列來存放任務及其回調(Timers 是用小頂堆存放)。基于這七個類型,Node 將事件循環的執行分為如下七個階段:
這個階段的執行優先級是最高的。
事件循環在這個階段會檢查存放定時器的數據結構(最小堆),對其中的定時器進行遍歷,逐個比較當前時間和過期時間,判斷該定時器是否過期,如果過期的話,就將該定時器的回調函數取出并執行。
該階段會執行網絡、IO 等異常時的回調。一些 *nix
上報的錯誤,在這個階段會得到處理。另外,一些應該在上輪循環的 poll 階段執行的 I/O 回調會被推遲到這個階段執行。
這兩個階段僅在事件循環內部使用。
檢索新的 I/O 事件;執行與 I/O 相關的回調(除了關閉回調、定時器調度的回調和 之外幾乎所有回調setImmediate()
);節點會在適當的時候阻塞在這里。
poll,即輪詢階段是事件循環最重要的階段,網絡 I/O、文件 I/O 的回調都主要在這個階段被處理。該階段有兩個主要功能:
計算該階段應該阻塞和輪詢 I/O 的時間。
處理 I/O 隊列中的回調。
當事件循環進入 poll 階段并且沒有設置定時器時:
如果輪詢隊列不為空,則事件循環將遍歷該隊列,同步地執行它們,直到隊列為空或達到可執行的最大數量。
如果輪詢隊列為空,則會發生另外兩種情況之一:
如果有 setImmediate()
回調需要執行,則立即結束 poll 階段,并進入 check 階段以執行回調。
如果沒有 setImmediate()
回調需要執行,事件循環將停留在該階段以等待回調被添加到隊列中,然后立即執行它們。在超時時間到達前,事件循環會一直停留等待。之所以選擇停留在這里是因為 Node 主要是處理 IO 的,這樣可以更及時地響應 IO。
一旦輪詢隊列為空,事件循環將檢查已達到時間閾值的定時器。如果有一個或多個定時器達到時間閾值,事件循環將回到 timers 階段以執行這些定時器的回調。
該階段會依次執行 setImmediate()
的回調。
該階段會執行一些關閉資源的回調,如 socket.on('close', ...)
。該階段晚點執行也影響不大,優先級最低。
當 Node 進程啟動時,它會初始化事件循環,執行用戶的輸入代碼,進行相應異步 API 的調用、計時器的調度等等,然后開始進入事件循環:
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
事件循環的每一輪循環(通常被稱為 tick),會按照如上給定的優先級順序進入七個階段的執行,每個階段會執行一定數量的隊列中的回調,之所以只執行一定數量而不全部執行完,是為了防止當前階段執行時間過長,避免下一個階段得不到執行。
OK,以上就是事件循環的基本執行流程。現在讓我們來看另外一個問題。
對于以下這個場景:
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
當服務成功綁定到 8000 端口,即 listen()
成功調用時,此時 listening
事件的回調還沒有綁定,因此端口成功綁定后,我們所傳入的 listening
事件的回調并不會執行。
再思考另外一個問題,我們在開發中可能會有一些需求,如處理錯誤、清理不需要的資源等等優先級不是那么高的任務,如果以同步的方式執行這些邏輯,就會影響當前任務的執行效率;如果以異步的方式,比如以回調的形式傳入 setImmediate()
又無法保證它們的執行時機,實時性不高。那么要如何處理這些邏輯呢?
基于這幾個問題,Node 參考了瀏覽器,也實現了一套微任務的機制。在 Node 中,除了調用 new Promise().then()
所傳入的回調函數會被封裝成微任務外,process.nextTick()
的回調也會被封裝成微任務,并且后者的執行優先級比前者高。
有了微任務后,事件循環的執行流程又是怎么樣的呢?換句話說,微任務的執行時機在什么時候?
在 node 11 及 11 之后的版本,一旦執行完一個階段里的一個任務就立刻執行微任務隊列,清空該隊列。
在 node11 之前執行完一個階段后才開始執行微任務。
因此,有了微任務后,事件循環的每一輪循環,會先執行 timers 階段的一個任務,然后按照先后順序清空 process.nextTick()
和 new Promise().then()
的微任務隊列,接著繼續執行 timers 階段的下一個任務或者下一個階段,即 pending 階段的一個任務,按照這樣的順序以此類推。
利用 process.nextTick()
,Node 就可以解決上面的端口綁定問題:在 listen()
方法內部,listening
事件的發出會被封裝成回調傳入 process.nextTick()
中,如下偽代碼所示:
function listen() { // 進行監聽端口的操作 ... // 將 `listening` 事件的發出封裝成回調傳入 `process.nextTick()` 中 process.nextTick(() => { emit('listening'); }); };
在當前代碼執行完畢后便會開始執行微任務,從而發出 listening
事件,觸發該事件回調的調用。
由于異步本身的不可預知性和復雜性,在使用 Node 提供的異步 API 的過程中,盡管我們已經掌握了事件循環的執行原理,但是仍可能會有一些不符合直覺或預期的現象產生。
比如定時器(setTimeout
、setImmediate
)的執行順序會因為調用它們的上下文而有所不同。如果兩者都是從頂層上下文中調用的,那么它們的執行時間取決于進程或機器的性能。
我們來看以下這個例子:
setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); });
以上代碼的執行結果是什么呢?按照我們剛才對事件循環的描述,你可能會有這樣的答案:由于 timers 階段會比 check 階段先執行,因此 setTimeout()
的回調會先執行,然后再執行 setImmediate()
的回調。
實際上,這段代碼的輸出結果是不確定的,可能先輸出 timeout,也可能先輸出 immediate。這是因為這兩個定時器都是在全局上下文中調用的,當事件循環開始運行并執行到 timers 階段時,當前時間可能大于 1 ms,也可能不足 1 ms,具體取決于機器的執行性能,因此 setTimeout()
在第一個 timers 階段是否會被執行實際上是不確定的,因此才會出現不同的輸出結果。
(當 delay
(setTimeout
的第二個參數)的值大于 2147483647
或小于 1
時, delay
會被設置為 1
。)
我們接著看下面這段代碼:
const fs = require('fs'); fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); }); });
可以看到,在這段代碼中兩個定時器都被封裝成回調函數傳入 readFile
中,很明顯當該回調被調用時當前時間肯定大于 1 ms 了,所以 setTimeout
的回調會比 setImmediate
的回調先得到調用,因此打印結果為:timeout immediate
。
讀到這里,這篇“Node異步和事件循環實例分析”文章已經介紹完畢,想要掌握這篇文章的知識點還需要大家自己動手實踐使用過才能領會,如果想了解更多相關內容的文章,歡迎關注億速云行業資訊頻道。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。