溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點(diǎn)擊 登錄注冊 即表示同意《億速云用戶服務(wù)條款》

Node異步和事件循環(huán)實(shí)例分析

發(fā)布時間:2022-07-21 09:49:22 來源:億速云 閱讀:148 作者:iii 欄目:開發(fā)技術(shù)

本文小編為大家詳細(xì)介紹“Node異步和事件循環(huán)實(shí)例分析”,內(nèi)容詳細(xì),步驟清晰,細(xì)節(jié)處理妥當(dāng),希望這篇“Node異步和事件循環(huán)實(shí)例分析”文章能幫助大家解決疑惑,下面跟著小編的思路慢慢深入,一起來學(xué)習(xí)新知識吧。

    為什么要異步?

    Node 為什么要使用異步來作為核心編程模型呢?

    前面說過,Node 最初是為打造高性能的 Web 服務(wù)器而生,假設(shè)業(yè)務(wù)場景中有幾組互不相關(guān)的任務(wù)要完成,現(xiàn)代主流的解決方式有以下兩種:

    • 單線程串行依次執(zhí)行。

    • 多線程并行完成。

    單線程串行依次執(zhí)行,是一種同步的編程模型,它雖然比較符合程序員按順序思考的思維方式,易寫出更順手的代碼,但由于是同步執(zhí)行 I/O,同一時刻只能處理單個請求,會導(dǎo)致服務(wù)器響應(yīng)速度較慢,無法在高并發(fā)的應(yīng)用場景下適用,且由于是阻塞 I/O,CPU 會一直等待 I/O 完成,無法做其他事情,使 CPU 的處理能力得不到充分利用,最終導(dǎo)致效率的低下,

    而多線程的編程模型也會因?yàn)榫幊讨械臓顟B(tài)同步、死鎖等問題讓開發(fā)人員頭疼。盡管多線程在多核 CPU 上能夠有效提升 CPU 的利用率。

    雖然單線程串行依次執(zhí)行和多線程并行完成的編程模型有其自身的優(yōu)勢,但是在性能、開發(fā)難度等方面也有不足之處。

    除此之外,從響應(yīng)客戶端請求的速度出發(fā),如果客戶端同時獲取兩個資源,同步方式的響應(yīng)速度會是兩個資源的響應(yīng)速度之和,而異步方式的響應(yīng)速度會是兩者中最大的一個,性能優(yōu)勢相比同步十分明顯。隨著應(yīng)用復(fù)雜度的增加,該場景會演變成同時響應(yīng) n 個請求,異步相比于同步的優(yōu)勢將會凸顯出來。

    綜上所述,Node 給出了它的答案:利用單線程,遠(yuǎn)離多線程死鎖、狀態(tài)同步等問題;利用異步 I/O,讓單線程遠(yuǎn)離阻塞,以更好地使用 CPU。這就是 Node 使用異步作為核心編程模型的原因。

    此外,為了彌補(bǔ)單線程無法利用多核 CPU 的缺點(diǎn),Node 也提供了類似瀏覽器中 Web Workers 的子進(jìn)程,該子進(jìn)程可以通過工作進(jìn)程高效地利用 CPU。

    如何實(shí)現(xiàn)異步?

    聊完了為什么要使用異步,那要如何實(shí)現(xiàn)異步呢?

    我們通常所說的異步操作總共有兩類:一是像文件 I/O、網(wǎng)絡(luò) I/O 這類與 I/O 有關(guān)的操作;二是像 setTimeOutsetInterval 這類與 I/O 無關(guān)的操作。很明顯我們所討論的異步是指與 I/O 有關(guān)的操作,即異步 I/O。

    異步 I/O 的提出是期望 I/O 的調(diào)用不會阻塞后續(xù)程序的執(zhí)行,將原有等待 I/O 完成的這段時間分配給其余需要的業(yè)務(wù)去執(zhí)行。要達(dá)到這個目的,就需要用到非阻塞 I/O。

    阻塞 I/O 是 CPU 在發(fā)起 I/O 調(diào)用后,會一直阻塞,等待 I/O 完成。知道了阻塞 I/O,非阻塞 I/O 就很好理解了,CPU 在發(fā)起 I/O 調(diào)用后會立即返回,而不是阻塞等待,在 I/O 完成之前,CPU 可以處理其他事務(wù)。顯然,相比于阻塞 I/O,非阻塞 I/O 多于性能的提升是很明顯的。

    那么,既然使用了非阻塞 I/O,CPU 在發(fā)起 I/O 調(diào)用后可以立即返回,那它是如何知道 I/O 完成的呢?答案是輪詢。

    為了及時獲取 I/O 調(diào)用的狀態(tài),CPU 會不斷重復(fù)調(diào)用 I/O 操作來確認(rèn) I/O 是否已經(jīng)完成,這種重復(fù)調(diào)用判斷操作是否完成的技術(shù)就叫做輪詢。

    顯然,輪詢會讓 CPU 不斷重復(fù)地執(zhí)行狀態(tài)判斷,是對 CPU 資源的浪費(fèi)。并且,輪詢的間間隔很難控制,如果間隔太長,I/O 操作的完成得不到及時的響應(yīng),間接降低應(yīng)用程序的響應(yīng)速度;如果間隔太短,難免會讓 CPU 花在輪詢的耗時變長,降低 CPU 資源的利用率。

    因此,輪詢雖然滿足了非阻塞 I/O 不會阻塞后續(xù)程序的執(zhí)行的要求,但是對于應(yīng)用程序而言,它仍然只能算是一種同步,因?yàn)閼?yīng)用程序仍然需要等待 I/O 完全返回,依舊花費(fèi)了很多時間來等待。

    我們所期望的完美的異步 I/O,應(yīng)該是應(yīng)用程序發(fā)起非阻塞調(diào)用,無須通過輪詢的方式不斷查詢 I/O 調(diào)用的狀態(tài),而是可以直接處理下一個任務(wù),在 I/O 完成后通過信號量或回調(diào)將數(shù)據(jù)傳遞給應(yīng)用程序即可。

    如何實(shí)現(xiàn)這種異步 I/O 呢?答案是線程池。

    雖然本文一直提到,Node 是單線程執(zhí)行的,但此處的單線程是指 JavaScript 代碼是執(zhí)行在單線程上的,對于 I/O 操作這類與主業(yè)務(wù)邏輯無關(guān)的部分,通過運(yùn)行在其他線程的方式實(shí)現(xiàn),并不會影響或阻塞主線程的運(yùn)行,反而可以提高主線程的執(zhí)行效率,實(shí)現(xiàn)異步 I/O。

    通過線程池,讓主線程僅進(jìn)行 I/O 的調(diào)用,讓其他多個線程進(jìn)行阻塞 I/O 或者非阻塞 I/O 加輪詢技術(shù)完成數(shù)據(jù)獲取,再通過線程之間的通信將 I/O 得到的數(shù)據(jù)進(jìn)行傳遞,這就輕松實(shí)現(xiàn)了異步 I/O:

    Node異步和事件循環(huán)實(shí)例分析

    主線程進(jìn)行 I/O 調(diào)用,而線程池進(jìn)行 I/O 操作,完成數(shù)據(jù)的獲取,然后通過線程之間的通信將數(shù)據(jù)傳遞給主線程,即可完成一次 I/O 的調(diào)用,主線程再利用回調(diào)函數(shù),將數(shù)據(jù)暴露給用戶,用戶再利用這些數(shù)據(jù)來完成業(yè)務(wù)邏輯層面的操作,這就是 Node 中一次完整的異步 I/O 流程。而對于用戶來說,不必在意底層這些繁瑣的實(shí)現(xiàn)細(xì)節(jié),只需要調(diào)用 Node 封裝好的異步 API,并傳入處理業(yè)務(wù)邏輯的回調(diào)函數(shù)即可,如下所示:

    const fs = require("fs");
    
    fs.readFile('example.js', (data) => {
      // 進(jìn)行業(yè)務(wù)邏輯的處理
    });

    Node 的異步底層實(shí)現(xiàn)機(jī)制在不同平臺下有所不同:Windows 下主要通過 IOCP 來向系統(tǒng)內(nèi)核發(fā)送 I/O 調(diào)用和從內(nèi)核獲取已完成的 I/O 操作,配以事件循環(huán),以此完成異步 I/O 的過程;Linux 下通過 epoll 實(shí)現(xiàn)這個過程;FreeBSD下通過 kqueue 實(shí)現(xiàn),Solaris 下通過 Event ports 實(shí)現(xiàn)。線程池在 Windows 下由內(nèi)核(IOCP)直接提供,*nix 系列則由 libuv 自行實(shí)現(xiàn)。

    由于 Windows 平臺和 *nix 平臺的差異,Node 提供了 libuv 作為抽象封裝層,使得所有平臺兼容性的判斷都由這一層來完成,保證上層的 Node 與下層的自定義線程池及 IOCP 之間各自獨(dú)立。Node 在編譯期間會判斷平臺條件,選擇性編譯 unix 目錄或是 win 目錄下的源文件到目標(biāo)程序中:

    Node異步和事件循環(huán)實(shí)例分析

    以上就是 Node 對異步的實(shí)現(xiàn)。

    (線程池的大小可以通過環(huán)境變量 UV_THREADPOOL_SIZE 設(shè)置,默認(rèn)值為 4,用戶可結(jié)合實(shí)際情況來調(diào)整這個值的大小。)

    那么問題來了,在得到線程池傳遞過來的數(shù)據(jù)后,主線程是如何、何時調(diào)用回調(diào)函數(shù)的呢?答案是事件循環(huán)。

    基于事件循環(huán)的異步編程模型

    既然使用回調(diào)函數(shù)來進(jìn)行對 I/O 數(shù)據(jù)的處理,就必然涉及到何時、如何調(diào)用回調(diào)函數(shù)的問題。在實(shí)際開發(fā)中,往往會涉及到多個、多類異步 I/O 調(diào)用的場景,如何合理安排這些異步 I/O 回調(diào)的調(diào)用,確保異步回調(diào)的有序進(jìn)行是一個難題,而且,除了異步 I/O 之外,還存在定時器這類非 I/O 的異步調(diào)用,這類 API 實(shí)時性強(qiáng),優(yōu)先級相應(yīng)地更高,如何實(shí)現(xiàn)不同優(yōu)先級回調(diào)地調(diào)度呢?

    因此,必須存在一個調(diào)度機(jī)制,對不同優(yōu)先級、不同類型的異步任務(wù)進(jìn)行協(xié)調(diào),確保這些任務(wù)在主線程上有條不紊地運(yùn)行。與瀏覽器一樣,Node 選擇了事件循環(huán)來承擔(dān)這項(xiàng)重任。

    Node 根據(jù)任務(wù)的種類和優(yōu)先級將它們分為七類:Timers、Pending、Idle、Prepare、Poll、Check、Close。對于每類任務(wù),都存在一個先進(jìn)先出的任務(wù)隊(duì)列來存放任務(wù)及其回調(diào)(Timers 是用小頂堆存放)。基于這七個類型,Node 將事件循環(huán)的執(zhí)行分為如下七個階段:

    timers

    這個階段的執(zhí)行優(yōu)先級是最高的。

    事件循環(huán)在這個階段會檢查存放定時器的數(shù)據(jù)結(jié)構(gòu)(最小堆),對其中的定時器進(jìn)行遍歷,逐個比較當(dāng)前時間和過期時間,判斷該定時器是否過期,如果過期的話,就將該定時器的回調(diào)函數(shù)取出并執(zhí)行。

    pending

    該階段會執(zhí)行網(wǎng)絡(luò)、IO 等異常時的回調(diào)。一些 *nix 上報的錯誤,在這個階段會得到處理。另外,一些應(yīng)該在上輪循環(huán)的 poll 階段執(zhí)行的 I/O 回調(diào)會被推遲到這個階段執(zhí)行。

    idle、prepare

    這兩個階段僅在事件循環(huán)內(nèi)部使用。

    poll

    檢索新的 I/O 事件;執(zhí)行與 I/O 相關(guān)的回調(diào)(除了關(guān)閉回調(diào)、定時器調(diào)度的回調(diào)和 之外幾乎所有回調(diào)setImmediate());節(jié)點(diǎn)會在適當(dāng)?shù)臅r候阻塞在這里。

    poll,即輪詢階段是事件循環(huán)最重要的階段,網(wǎng)絡(luò) I/O、文件 I/O 的回調(diào)都主要在這個階段被處理。該階段有兩個主要功能:

    • 計算該階段應(yīng)該阻塞和輪詢 I/O 的時間。

    • 處理 I/O 隊(duì)列中的回調(diào)。

    當(dāng)事件循環(huán)進(jìn)入 poll 階段并且沒有設(shè)置定時器時:

    • 如果輪詢隊(duì)列不為空,則事件循環(huán)將遍歷該隊(duì)列,同步地執(zhí)行它們,直到隊(duì)列為空或達(dá)到可執(zhí)行的最大數(shù)量。

    • 如果輪詢隊(duì)列為空,則會發(fā)生另外兩種情況之一:

      • 如果有 setImmediate() 回調(diào)需要執(zhí)行,則立即結(jié)束 poll 階段,并進(jìn)入 check 階段以執(zhí)行回調(diào)。

      • 如果沒有 setImmediate() 回調(diào)需要執(zhí)行,事件循環(huán)將停留在該階段以等待回調(diào)被添加到隊(duì)列中,然后立即執(zhí)行它們。在超時時間到達(dá)前,事件循環(huán)會一直停留等待。之所以選擇停留在這里是因?yàn)?Node 主要是處理 IO 的,這樣可以更及時地響應(yīng) IO。

    一旦輪詢隊(duì)列為空,事件循環(huán)將檢查已達(dá)到時間閾值的定時器。如果有一個或多個定時器達(dá)到時間閾值,事件循環(huán)將回到 timers 階段以執(zhí)行這些定時器的回調(diào)。

    check

    該階段會依次執(zhí)行 setImmediate() 的回調(diào)。

    close

    該階段會執(zhí)行一些關(guān)閉資源的回調(diào),如 socket.on('close', ...)。該階段晚點(diǎn)執(zhí)行也影響不大,優(yōu)先級最低。

    當(dāng) Node 進(jìn)程啟動時,它會初始化事件循環(huán),執(zhí)行用戶的輸入代碼,進(jìn)行相應(yīng)異步 API 的調(diào)用、計時器的調(diào)度等等,然后開始進(jìn)入事件循環(huán):

       ┌───────────────────────────┐
    ┌─>│           timers          │
    │  └─────────────┬─────────────┘
    │  ┌─────────────┴─────────────┐
    │  │     pending callbacks     │
    │  └─────────────┬─────────────┘
    │  ┌─────────────┴─────────────┐
    │  │       idle, prepare       │
    │  └─────────────┬─────────────┘      ┌───────────────┐
    │  ┌─────────────┴─────────────┐      │   incoming:   │
    │  │           poll            │<─────┤  connections, │
    │  └─────────────┬─────────────┘      │   data, etc.  │
    │  ┌─────────────┴─────────────┐      └───────────────┘
    │  │           check           │
    │  └─────────────┬─────────────┘
    │  ┌─────────────┴─────────────┐
    └──┤      close callbacks      │
       └───────────────────────────┘

    事件循環(huán)的每一輪循環(huán)(通常被稱為 tick),會按照如上給定的優(yōu)先級順序進(jìn)入七個階段的執(zhí)行,每個階段會執(zhí)行一定數(shù)量的隊(duì)列中的回調(diào),之所以只執(zhí)行一定數(shù)量而不全部執(zhí)行完,是為了防止當(dāng)前階段執(zhí)行時間過長,避免下一個階段得不到執(zhí)行。

    OK,以上就是事件循環(huán)的基本執(zhí)行流程?,F(xiàn)在讓我們來看另外一個問題。

    對于以下這個場景:

    const server = net.createServer(() => {}).listen(8080);
    
    server.on('listening', () => {});

    當(dāng)服務(wù)成功綁定到 8000 端口,即 listen() 成功調(diào)用時,此時 listening 事件的回調(diào)還沒有綁定,因此端口成功綁定后,我們所傳入的 listening 事件的回調(diào)并不會執(zhí)行。

    再思考另外一個問題,我們在開發(fā)中可能會有一些需求,如處理錯誤、清理不需要的資源等等優(yōu)先級不是那么高的任務(wù),如果以同步的方式執(zhí)行這些邏輯,就會影響當(dāng)前任務(wù)的執(zhí)行效率;如果以異步的方式,比如以回調(diào)的形式傳入 setImmediate() 又無法保證它們的執(zhí)行時機(jī),實(shí)時性不高。那么要如何處理這些邏輯呢?

    基于這幾個問題,Node 參考了瀏覽器,也實(shí)現(xiàn)了一套微任務(wù)的機(jī)制。在 Node 中,除了調(diào)用 new Promise().then() 所傳入的回調(diào)函數(shù)會被封裝成微任務(wù)外,process.nextTick() 的回調(diào)也會被封裝成微任務(wù),并且后者的執(zhí)行優(yōu)先級比前者高。

    有了微任務(wù)后,事件循環(huán)的執(zhí)行流程又是怎么樣的呢?換句話說,微任務(wù)的執(zhí)行時機(jī)在什么時候?

    • 在 node 11 及 11 之后的版本,一旦執(zhí)行完一個階段里的一個任務(wù)就立刻執(zhí)行微任務(wù)隊(duì)列,清空該隊(duì)列。

    • 在 node11 之前執(zhí)行完一個階段后才開始執(zhí)行微任務(wù)。

    因此,有了微任務(wù)后,事件循環(huán)的每一輪循環(huán),會先執(zhí)行 timers 階段的一個任務(wù),然后按照先后順序清空 process.nextTick()new Promise().then() 的微任務(wù)隊(duì)列,接著繼續(xù)執(zhí)行 timers 階段的下一個任務(wù)或者下一個階段,即 pending 階段的一個任務(wù),按照這樣的順序以此類推。

    利用 process.nextTick(),Node 就可以解決上面的端口綁定問題:在 listen() 方法內(nèi)部,listening 事件的發(fā)出會被封裝成回調(diào)傳入 process.nextTick() 中,如下偽代碼所示:

    function listen() {
        // 進(jìn)行監(jiān)聽端口的操作
        ...
        // 將 `listening` 事件的發(fā)出封裝成回調(diào)傳入 `process.nextTick()` 中
        process.nextTick(() => {
            emit('listening');
        });
    };

    在當(dāng)前代碼執(zhí)行完畢后便會開始執(zhí)行微任務(wù),從而發(fā)出 listening 事件,觸發(fā)該事件回調(diào)的調(diào)用。

    一些注意事項(xiàng)

    由于異步本身的不可預(yù)知性和復(fù)雜性,在使用 Node 提供的異步 API 的過程中,盡管我們已經(jīng)掌握了事件循環(huán)的執(zhí)行原理,但是仍可能會有一些不符合直覺或預(yù)期的現(xiàn)象產(chǎn)生。

    比如定時器(setTimeout、setImmediate)的執(zhí)行順序會因?yàn)檎{(diào)用它們的上下文而有所不同。如果兩者都是從頂層上下文中調(diào)用的,那么它們的執(zhí)行時間取決于進(jìn)程或機(jī)器的性能。

    我們來看以下這個例子:

    setTimeout(() => {
      console.log('timeout');
    }, 0);
    
    setImmediate(() => {
      console.log('immediate');
    });

    以上代碼的執(zhí)行結(jié)果是什么呢?按照我們剛才對事件循環(huán)的描述,你可能會有這樣的答案:由于 timers 階段會比 check 階段先執(zhí)行,因此 setTimeout() 的回調(diào)會先執(zhí)行,然后再執(zhí)行 setImmediate() 的回調(diào)。

    實(shí)際上,這段代碼的輸出結(jié)果是不確定的,可能先輸出 timeout,也可能先輸出 immediate。這是因?yàn)檫@兩個定時器都是在全局上下文中調(diào)用的,當(dāng)事件循環(huán)開始運(yùn)行并執(zhí)行到 timers 階段時,當(dāng)前時間可能大于 1 ms,也可能不足 1 ms,具體取決于機(jī)器的執(zhí)行性能,因此 setTimeout() 在第一個 timers 階段是否會被執(zhí)行實(shí)際上是不確定的,因此才會出現(xiàn)不同的輸出結(jié)果。

    (當(dāng) delaysetTimeout 的第二個參數(shù))的值大于 2147483647 或小于 1 時, delay 會被設(shè)置為 1。)

    我們接著看下面這段代碼:

    const fs = require('fs');
    
    fs.readFile(__filename, () => {
      setTimeout(() => {
        console.log('timeout');
      }, 0);
      setImmediate(() => {
        console.log('immediate');
      });
    });

    可以看到,在這段代碼中兩個定時器都被封裝成回調(diào)函數(shù)傳入 readFile 中,很明顯當(dāng)該回調(diào)被調(diào)用時當(dāng)前時間肯定大于 1 ms 了,所以 setTimeout 的回調(diào)會比 setImmediate 的回調(diào)先得到調(diào)用,因此打印結(jié)果為:timeout immediate

    讀到這里,這篇“Node異步和事件循環(huán)實(shí)例分析”文章已經(jīng)介紹完畢,想要掌握這篇文章的知識點(diǎn)還需要大家自己動手實(shí)踐使用過才能領(lǐng)會,如果想了解更多相關(guān)內(nèi)容的文章,歡迎關(guān)注億速云行業(yè)資訊頻道。

    向AI問一下細(xì)節(jié)

    免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進(jìn)行舉報,并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。

    AI