站長資訊網
        最全最豐富的資訊網站

        Nodejs進階學習:深入了解異步I/O和事件循環

        本篇文章是Nodejs的進階學習,帶大家詳細了解一下Nodejs中的異步I/O和事件循環,希望對大家有所幫助!

        Nodejs進階學習:深入了解異步I/O和事件循環

        本文講詳細講解 nodejs 中兩個比較難以理解的部分異步I/O事件循環,對 nodejs 核心知識點,做梳理和補充。【推薦學習:《nodejs 教程》】

        送人玫瑰,手有余香,希望閱讀后感覺不錯的同學,可以給點個贊,鼓勵我繼續創作前端硬文。

        老規矩我們帶上疑問開始今天的分析:

        • 1 說說 nodejs 的異步I/O ?
        • 2 說說 nodejs 的事件循環機制 ?
        • 3 介紹一下 nodejs 中事件循環的各個階段 ?
        • 4 nodejs 中 promise 和 nextTick 的區別?
        • 5 nodejs 中 setImmediate 和 setTimeout 區別 ?
        • 6 setTimeout 是精確的嗎,什么情況影響 setTimeout 的執行?
        • 7 nodejs 中事件循環和瀏覽器有什么不同 ?

        異步I/O

        概念

        處理器訪問任何寄存器和 Cache 等封裝以外的數據資源都可以當成 I/O 操作,包括內存,磁盤,顯卡等外部設備。在 Nodejs 中像開發者調用 fs 讀取本地文件或網絡請求等操作都屬于I/O操作。(最普遍抽象 I/O 是文件操作和 TCP/UDP 網絡操作)

        Nodejs 為單線程的,在單線程模式下,任務都是順序執行的,但是前面的任務如果用時過長,那么勢必會影響到后續任務的進行,通常 I/O 與 cpu 之間的計算是可以并行進行的,但是同步的模式下,I/O的進行會導致后續任務的等待,這樣阻塞了任務的執行,也造成了資源不能很好的利用。

        為了解決如上的問題,Nodejs 選擇了異步I/O的模式,讓單線程不再阻塞,更合理的使用資源。

        如何合理的看待Nodejs中異步I/O

        前端開發者可能更清晰瀏覽器環境下的 JS 的異步任務,比如發起一次 ajax 請求,正如 ajax 是瀏覽器提供給 js 執行環境下可以調用的 api 一樣 ,在 Nodejs 中提供了 http 模塊可以讓 js 做相同的事。比如監聽|發送 http 請求,除了 http 之外,nodejs 還有操作本地文件的 fs 文件系統等。

        如上 fs http 這些任務在 nodejs 中叫做 I/O 任務。理解了 I/O 任務之后,來分析一下在 Nodejs 中,I/O 任務的兩種形態——阻塞和非阻塞。

        nodejs中同步和異步IO模式

        nodejs 對于大部分的 I/O 操作都提供了阻塞非阻塞兩種用法。阻塞指的是執行 I/O 操作的時候必須等待結果,才往下執行 js 代碼。如下一下阻塞代碼

        同步I/O模式

        /* TODO:  阻塞 */ const fs = require('fs'); const data = fs.readFileSync('./file.js'); console.log(data)
        • 代碼阻塞 :讀取同級目錄下的 file.js 文件,結果 databuffer 結構,這樣當讀取過程中,會阻塞代碼的執行,所以 console.log(data) 將被阻塞,只有當結果返回的時候,才能正常打印 data
        • 異常處理 :如上操作有一個致命點就是,如果出現了異常,(比如在同級目錄下沒有 file.js 文件),就會讓整個程序報錯,接下來的代碼講不會執行。通常需要 try catch來捕獲錯誤邊界。代碼如下:
        /* TODO: 阻塞 - 捕獲異常  */ try{     const fs = require('fs');     const data = fs.readFileSync('./file1.js');     console.log(data) }catch(e){     console.log('發生錯誤:',e) } console.log('正常執行')
        • 如上即便發生了錯誤,也不會影響到后續代碼的執行以及應用程序發生錯誤導致的退出。

        同步 I/O 模式造成代碼執行等待 I/O 結果,浪費等待時間,CPU 的處理能力得不到充分利用,I/O 失敗還會讓整整個線程退出。阻塞 I / O 在整個調用棧上示意圖如下:

        Nodejs進階學習:深入了解異步I/O和事件循環

        異步I/O模式

        這就是剛剛介紹的異步I/O。首先看一下異步模式下的 I/O 操作:

        /* TODO: 非阻塞 - 異步 I/O */ const fs = require('fs') fs.readFile('./file.js',(err,data)=>{     console.log(err,data) // null  <Buffer 63 6f 6e 73 6f 6c 65 2e 6c 6f 67 28 27 68 65 6c 6c 6f 2c 77 6f 72 6c 64 27 29> }) console.log(111) // 111 先被打印~  fs.readFile('./file1.js',(err,data)=>{     console.log(err,data) // 保存  [ no such file or directory, open './file1.js'] ,找不到文件。 })
        • 回調 callback 被異步執行,返回的第一個參數是錯誤信息,如果沒有錯誤,那么返回 null ,第二個參數為 fs.readFile 執行得到的真正內容。
        • 這種異步的形式可以會優雅的捕獲到執行 I/O 中出現的錯誤,比如說如上當讀取 file1.js 文件時候,出現了找不到對應文件的異常行為,會直接通過第一個參數形式傳遞到 callback 中。

        比如如上的 callback ,作為一個異步回調函數,就像 setTimeout(fn) 的 fn 一樣,不會阻塞代碼執行。會在得到結果后觸發,對于 Nodejs 異步執行 I/O 回調的細節,接下來會慢慢剖析。

        對于異步 I/O 的處理, Nodejs 內部使用了線程池來處理異步 I/O 任務,線程池中會有多個 I/O 線程來同時處理異步的 I/O 操作,比如如上的的例子中,在整個 I/O 模型中會這樣。

        Nodejs進階學習:深入了解異步I/O和事件循環

        接下來將一起探索一下異步 I/O 執行過程。

        事件循環

        和瀏覽器一樣,Nodejs 也有自身的執行模型——事件循環( eventLoop ),事件循環的執行模型受到宿主環境的影響,它不屬于 javascript 執行引擎( 例如 v8 )的一部分,這就導致了不同宿主環境下事件循環模式和機制可能不同,直觀的體現就是 Nodejs 和瀏覽器環境下對微任務( microtask )和宏任務( macrotask )處理存在差異。對于 Nodejs 的事件循環及其每一個階段,接下來會詳細探討。

        Nodejs 的事件循環有多個階段,其中有一個專門處理 I/O 回調的階段,每一個執行階段我們可以稱之為 Tick , 每一個 Tick 都會查詢是否還有事件以及關聯的回調函數 ,如上異步 I/O 的回調函數,會在 I/O 處理階段檢查當前 I/O 是否完成,如果完成,那么執行對應的 I/O 回調函數,那么這個檢查 I/O 是否完成的觀察者我們稱之為 I/O 觀察者。

        觀察者

        如上提到了 I/O 觀察者的概念,也講了 Nodejs 中會有多個階段,事實上每一個階段都有一個或者多個對應的觀察者,它們的工作很明確就是在每一次對應的 Tick 過程中,對應的觀察者查找有沒有對應的事件執行,如果有,那么取出來執行。

        瀏覽器的事件來源于用戶的交互和一些網絡請求比如 ajax 等, Nodejs 中,事件來源于網絡請求 http ,文件 I/O 等,這些事件都有對應的觀察者,我這里枚舉出一些重要的觀察者。

        • 文件 I/O 操作 —— I/O 觀察者;
        • 網絡 I/O 操作 —— 網絡 I/O 觀察者;
        • process.nextTick —— idle 觀察者
        • setImmediate —— check 觀察者
        • setTimeout/setInterval —— 延時器觀察者

        在 Nodejs 中,對應觀察者接收對應類型的事件,事件循環過程中,會向這些觀察者詢問有沒有該執行的任務,如果有,那么觀察者會取出任務,交給事件循環去執行。

        請求對象與線程池

        JavaScript 調用到計算機系統執行完 I/O 回調,請求對象充當著很重要的作用,我們還是以一次異步 I/O 操作為例

        請求對象: 比如之前調用 fs.readFile ,本質上調用 libuv 上的方法創建一個請求對象。這個請求對象上保留著此次 I/O 請求的信息,包括此次 I/O 的主體和回調函數等。然后異步調用的第一階段就完成了,JavaScript 會繼續往下執行執行棧上的代碼邏輯,當前的 I/O 操作將以請求對象的形式放入到線程池中,等待執行。達到了異步 I/O 的目的。

        線程池: Nodejs 的線程池在 Windows 下有內核( IOCP )提供,在 Unix 系統中由 libuv 自行實現, 線程池用來執行部分的 I/O (系統文件的操作),線程池大小默認為 4 ,多個文件系統操作的請求可能阻塞到一個線程中。那么線程池里面的 I/O 操作是怎么執行的呢? 上一步說到,一次異步 I/O 會把請求對象放在線程池中,首先會判斷當前線程池是否有可用的線程,如果線程可用,那么會執行請求對象的 I/O 操作,并把執行后的結果返回給請求對象。在事件循環中的 I/O 處理階段,I/O 觀察者會獲取到已經完成的 I/O 對象,然后取出回調函數和結果調用執行。I/O 回調函數就這樣執行,而且在回調函數的參數重獲取到結果。

        異步 I/O 操作機制

        上述講了整個異步 I/O 的執行流程,從一個異步 I/O 的觸發,到 I/O 回調到執行。事件循環觀察者請求對象 ,線程池 構成了整個異步 I/O 執行模型。

        用一幅圖表示四者的關系:

        Nodejs進階學習:深入了解異步I/O和事件循環

        總結上述過程:

        • 第一階段:每一次異步 I/O 的調用,首先在 nodejs 底層設置請求參數和回調函 callback,形成請求對象

        • 第二階段:形成的請求對象,會被放入線程池,如果線程池有空閑的 I/O 線程,會執行此次 I/O 任務,得到結果。

        • 第三階段:事件循環I/O 觀察者,會從請求對象中找到已經得到結果的 I/O 請求對象,取出結果和回調函數,將回調函數放入事件循環中,執行回調,完成整個異步 I/O 任務。

        • 對于如何感知異步 I/O 任務執行完畢的?以及如何獲取完成的任務的呢? libuv 作為中間層, 在不同平臺上,采用手段不同,在 unix 下通過 epoll 輪詢,在 Windows 下通過內核( IOCP )來實現 ,FreeBSD 下通過 kqueue 實現。

        事件循環

        事件循環機制由宿主環境實現

        上述中已經提及了事件循環不是 JavaScript 引擎的一部分 ,事件循環機制由宿主環境實現,所以不同宿主環境下事件循環不同 ,不同宿主環境指的是瀏覽器環境還是 nodejs 環境 ,但在不同操作系統中,nodejs 的宿主環境也是不同的,接下來用一幅圖描述一下 Nodejs 中的事件循環和 javascript 引擎之間的關系。

        以 libuv 下 nodejs 的事件循環為參考,關系如下:

        Nodejs進階學習:深入了解異步I/O和事件循環

        以瀏覽器下 javaScript 的事件循環為參考,關系如下:

        Nodejs進階學習:深入了解異步I/O和事件循環

        事件循環本質上就像一個 while 循環,如下所示,我來用一段代碼模擬事件循環的執行流程。

        const queue = [ ... ]   // queue 里面放著待處理事件 while(true){     //開始循環     //執行 queue 中的任務     //....      if(queue.length ===0){        return // 退出進程     } }
        • Nodejs 啟動后,就像創建一個 while 循環一樣,queue 里面放著待處理的事件,每一次循環過程中,如果還有事件,那么取出事件,執行事件,如果存在事件關聯的回調函數,那么執行回調函數,然后開始下一次循環。
        • 如果循環體中沒有事件,那么將退出進程。

        我總結了流程圖如下所示:

        Nodejs進階學習:深入了解異步I/O和事件循環

        那么如何事件循環是如何處理這些任務的呢?我們列出 Nodejs 中一些常用的事件任務:

        • setTimeoutsetInterval 延時器計時器。
        • 異步 I/O 任務:文件任務 ,網絡請求等。
        • setImmediate 任務。
        • process.nextTick 任務。
        • Promise 微任務。

        接下來會一一講到 ,這些任務的原理以及 nodejs 是如何處理這些任務的。

        1 事件循環階段

        對于不同的事件任務,會在不同的事件循環階段執行。根據 nodejs 官方文檔,在通常情況下,nodejs 中的事件循環根據不同的操作系統可能存在特殊的階段,但總體是可以分為以下 6 個階段 (代碼塊的六個階段) :

        /*    ┌───────────────────────────┐ ┌─>│           timers          │     -> 定時器,延時器的執行     │  └─────────────┬─────────────┘ │  ┌─────────────┴─────────────┐ │  │     pending callbacks     │     -> i/o │  └─────────────┬─────────────┘ │  ┌─────────────┴─────────────┐ │  │       idle, prepare       │ │  └─────────────┬─────────────┘      ┌───────────────┐ │  ┌─────────────┴─────────────┐      │   incoming:   │ │  │           poll            │<─────┤  connections, │ │  └─────────────┬─────────────┘      │   data, etc.  │ │  ┌─────────────┴─────────────┐      └───────────────┘ │  │           check           │ │  └─────────────┬─────────────┘ │  ┌─────────────┴─────────────┐ └──┤      close callbacks      │    └───────────────────────────┘ */
        • 第一階段: timer ,timer 階段主要做的事是,執行 setTimeoutsetInterval 注冊的回調函數。

        • 第二階段:pending callback ,大部分 I/O 回調任務都是在 poll 階段執行的,但是也會存在一些上一次事件循環遺留的被延時的 I/O 回調函數,那么此階段就是為了調用之前事件循環延遲執行的 I/O 回調函數。

        • 第三階段:idle prepare 階段,僅用于 nodejs 內部模塊的使用。

        • 第四階段:poll 輪詢階段,這個階段主要做兩件事,一這個階段會執行異步 I/O 的回調函數; 二 計算當前輪詢階段阻塞后續階段的時間。

        • 第五階段:check階段,當 poll 階段回調函數隊列為空的時候,開始進入 check 階段,主要執行 setImmediate 回調函數。

        • 第六階段:close階段,執行注冊 close 事件的回調函數。

        對于每一個階段的執行特點和對應的事件任務,我接下來會詳細剖析。我們看一下六個階段在底層源碼中是怎么樣體現的。

        我們看一下 libuv 下 nodejs 的事件循環的源代碼(在 unixwin 有點差別,不過不影響流程,這里以 unix 為例子。):

        libuv/src/unix/core.c

        int uv_run(uv_loop_t* loop, uv_run_mode mode) {   // 省去之前的流程。   while (r != 0 && loop->stop_flag == 0) {      /* 更新事件循環的時間 */      uv__update_time(loop);      /*第一階段: timer 階段執行  */     uv__run_timers(loop);      /*第二階段: pending 階段 */     ran_pending = uv__run_pending(loop);      /*第三階段: idle prepare 階段 */     uv__run_idle(loop);     uv__run_prepare(loop);      timeout = 0;     if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)      /* 計算 timeout 時間  */       timeout = uv_backend_timeout(loop);          /* 第四階段:poll 階段 */     uv__io_poll(loop, timeout);      /* 第五階段:check 階段 */     uv__run_check(loop);     /* 第六階段: close 階段  */     uv__run_closing_handles(loop);     /* 判斷當前線程還有任務 */       r = uv__loop_alive(loop);      /* 省去之后的流程 */   }   return r; }
        • 我們看到六個階段是按序執行的,只有完成上一階段的任務,才能進行下一階段
        • uv__loop_alive 判斷當前事件循環沒有任務,那么退出線程。

        2 任務隊列

        在整個事件循環過程中,有四個隊列(實際的數據結構不是隊列)是在 libuv 的事件循環中進行的,還有兩個隊列是在 nodejs 中執行的分別是 promise 隊列nextTick 隊列。

        在 NodeJS 中不止一個隊列,不同類型的事件在它們自己的隊列中入隊。在處理完一個階段后,移向下一個階段之前,事件循環將會處理兩個中間隊列,直到兩個中間隊列為空。

        libuv 處理任務隊列

        事件循環的每一個階段,都會執行對應任務隊列里面的內容。

        • timer 隊列( PriorityQueue ):本質上的數據結構是二叉最小堆,二叉最小堆的根節點獲取最近的時間線上的 timer 對應的回調函數。

        • I/O 事件隊列:存放 I/O 任務。

        • Immediate 隊列( ImmediateList ):多個 Immediate ,node 層用鏈表數據結構儲存。

        • 關閉回調事件隊列:放置待 close 的回調函數。

        非 libuv 中間隊列

        • nextTick 隊列 : 存放 nextTick 的回調函數。這個是在 nodejs 中特有的。
        • Microtasks 微隊列 Promise : 存放 promise 的回調函數。

        中間隊列的執行特點:

        • 首先要明白兩個中間隊列并非在 libuv 中被執行,它們都是在 nodejs 層執行的,在 libuv 層處理每一個階段的任務之后,會和 node 層進行通訊,那么會優先處理兩個隊列中的任務。

        • nextTick 任務的優先級要大于 Microtasks 任務中的 Promise 回調。也就是說 node 會首先清空 nextTick 中的任務,然后才是 Promise 中的任務。為了驗證這個結論,例舉一個打印結果的題目如下:

        /* TODO: 打印順序  */ setTimeout(()=>{     console.log('setTimeout 執行') },0)  const p = new Promise((resolve)=>{      console.log('Promise執行')      resolve() }) p.then(()=>{     console.log('Promise 回調執行') })  process.nextTick(()=>{     console.log('nextTick 執行') }) console.log('代碼執行完畢')

        如上代碼塊中的 nodejs 中的執行順序是什么?

        效果:

        Nodejs進階學習:深入了解異步I/O和事件循環

        打印結果:Promise執行 -> 代碼執行完畢 -> nextTick 執行 -> Promise 回調執行 -> setTimeout 執行

        解釋:很好理解為什么這么打印,在主代碼事件循環中, Promise執行代碼執行完畢 最先被打印,nextTick 被放入 nextTick 隊列中,Promise 回調放入 Microtasks 隊列中,setTimeout 被放入 timer 堆中。接下來主循環完成,開始清空兩個隊列中的內容,首先清空 nextTick 隊列,nextTick 執行 被打印,接下來清空 Microtasks 隊列,Promise 回調執行 被打印,最后再判斷事件循環 loop 中還有 timer 任務,那么開啟新的事件循環 ,首先執行,timer 任務,setTimeout 執行被打印。 整個流程完畢。

        • 無論是 nextTick 的任務,還是 promise 中的任務, 兩個任務中的代碼會阻塞事件循環的有序進行,導致 I/O 餓死的情況發生,所以需要謹慎處理兩個任務中的邏輯。比如如下:
        /* TODO: 阻塞 I/O 情況 */ process.nextTick(()=>{     const now = +new Date()     /* 阻塞代碼三秒鐘 */     while( +new Date() < now + 3000 ){} })  fs.readFile('./file.js',()=>{     console.log('I/O: file ') })  setTimeout(() => {     console.log('setTimeout: ') }, 0);

        效果:

        Nodejs進階學習:深入了解異步I/O和事件循環

        • 三秒鐘, 事件循環中的 timer 任務和 I/O 任務,才被有序執行。也就是說 nextTick 中的代碼,阻塞了事件循環的有序進行。

        3 事件循環流程圖

        接下來用流程圖,表示事件循環的六大階段的執行順序,以及兩個優先隊列的執行邏輯。

        Nodejs進階學習:深入了解異步I/O和事件循環

        4 timer 階段 -> 計時器 timer / 延時器 interval

        延時器計時器觀察者(Expired timers and intervals):延時器計時器觀察者用來檢查通過 setTimeoutsetInterval創建的異步任務,內部原理和異步 I/O 相似,不過定期器/延時器內部實現沒有用線程池。通過setTimeoutsetInterval定時器對象會被插入到延時器計時器觀察者內部的二叉最小堆中,每次事件循環過程中,會從二叉最小堆頂部取出計時器對象,判斷 timer/interval 是否過期,如果有,然后調用它,出隊。再檢查當前隊列的第一個,直到沒有過期的,移到下一個階段。

        libuv 層如何處理 timer

        首先一起看一下 libuv 層是如何處理的 timer

        libuv/src/timer.c

        void uv__run_timers(uv_loop_t* loop) {   struct heap_node* heap_node;   uv_timer_t* handle;    for (;;) {     /* 找到 loop 中 timer_heap 中的根節點 ( 值最小 ) */       heap_node = heap_min((struct heap*) &loop->timer_heap);     /*  */     if (heap_node == NULL)       break;      handle = container_of(heap_node, uv_timer_t, heap_node);     if (handle->timeout > loop->time)       /*  執行時間大于事件循環事件,那么不需要在此次 loop 中執行  */       break;      uv_timer_stop(handle);     uv_timer_again(handle);     handle->timer_cb(handle);   } }
        • 如上 handle timeout 可以理解成過期時間,也就是計時器回到函數的執行時間。
        • 當 timeout 大于當前事件循環的開始時間時,即表示還沒有到執行時機,回調函數還不應該被執行。那么根據二叉最小堆的性質,父節點始終比子節點小,那么根節點的時間節點都不滿足執行時機的話,其他的 timer 也不滿足執行時間。此時,退出 timer 階段的回調函數執行,直接進入事件循環下一階段。
        • 當過期時間小于當前事件循環 tick 的開始時間時,表示至少存在一個過期的計時器,那么循環迭代計時器最小堆的根節點,并調用該計時器所對應的回調函數。每次循環迭代時都會更新最小堆的根節點為最近時間節點的計時器。

        如上是 timer 階段在 libuv 中執行特點。接下里分析一下 node 中是如何處理定時器延時器的。

        node 層如何處理 timer

        在 Nodejs 中 setTimeoutsetInterval 是 nodejs 自己實現的,來一起看一下實現細節:

        node/lib/timers.js

        function setTimeout(callback,after){     //...     /* 判斷參數邏輯 */     //..     /* 創建一個 timer 觀察者 */     const timeout = new Timeout(callback, after, args, false, true);     /* 將 timer 觀察者插入到 timer 堆中  */     insert(timeout, timeout._idleTimeout);      return timeout; }
        • setTimeout: 邏輯很簡單,就是創建一個 timer 時間觀察者,然后放入計時器堆中。

        那么 Timeout 做了些什么呢?

        node/lib/internal/timers.js

        function Timeout(callback, after, args, isRepeat, isRefed) {   after *= 1    if (!(after >= 1 && after <= 2 ** 31 - 1)) {     after = 1 // 如果延時器 timeout 為 0 ,或者是大于 2 ** 31 - 1 ,那么設置成 1    }   this._idleTimeout = after; // 延時時間    this._idlePrev = this;   this._idleNext = this;   this._idleStart = null;   this._onTimeout = null;   this._onTimeout = callback; // 回調函數   this._timerArgs = args;   this._repeat = isRepeat ? after : null;   this._destroyed = false;      initAsyncResource(this, 'Timeout'); }
        • 在 nodejs 中無論 setTimeout 還是 setInterval 本質上都是 Timeout 類。超出最大時間閥 2 ** 31 - 1 或者 setTimeout(callback, 0) ,_idleTimeout 會被設置成 1 ,轉換為 setTimeout(callback, 1) 來執行。

        timer 處理流程圖

        用一副流程圖描述一下,我們創建一個 timer ,再到 timer 在事件循環里面執行的流程。

        Nodejs進階學習:深入了解異步I/O和事件循環

        timer 特性

        這里有兩點需要注意:

        • 執行機制 :延時器計時器觀察者,每一次都會執行一個,執行一個之后會清空 nextTick 和 Promise, 過期時間是決定兩者是否執行的重要因素,還有一點 poll 會計算阻塞 timer 執行的時間,對 timer 階段任務的執行也有很重要的影響。

        驗證結論一次執行一個 timer 任務 ,先來看一段代碼片段:

        setTimeout(()=>{     console.log('setTimeout1:')     process.nextTick(()=>{         console.log('nextTick')     }) },0) setTimeout(()=>{     console.log('setTimeout2:') },0)

        打印結果:

        Nodejs進階學習:深入了解異步I/O和事件循環

        nextTick 隊列是在事件循環的每一階段結束執行的,兩個延時器的閥值都是 0 ,如果在 timer 階段一次性執行完,過期任務的話,那么打印 setTimeout1 -> setTimeout2 -> nextTick ,實際上先執行一個 timer 任務,然后執行 nextTick 任務,最后再執行下一個 timer 任務。

        • 精度問題 :關于 setTimeout 的計數器問題,計時器并非精確的,盡管在 nodejs 的事件循環非常的快,但是從延時器 timeout 類的創建,會占用一些事件,再到上下文執行, I/O 的執行,nextTick 隊列執行,Microtasks 執行,都會阻塞延時器的執行。甚至在檢查 timer 過期的時候,也會消耗一些 cpu 時間。

        • 性能問題 :如果想用 setTimeout(fn,0) 來執行一些非立即調用的任務,那么性能上不如 process.nextTick 實在,首先 setTimeout 精度不夠,還有一點就是里面有定時器對象,并需要在 libuv 底層執行,占用一定性能,所以可以用 process.nextTick 解決這種場景。

        5 pending 階段

        pending 階段用來處理此次事件循環之前延時的 I/O 回調函數。首先看一下在 libuv 中執行時機。

        libuv/src/unix/core.c

        static int uv__run_pending(uv_loop_t* loop) {   QUEUE* q;   QUEUE pq;   uv__io_t* w   /* pending_queue 為空,清空隊列 ,返回 0  */   if (QUEUE_EMPTY(&loop->pending_queue))     return 0;      QUEUE_MOVE(&loop->pending_queue, &pq);   while (!QUEUE_EMPTY(&pq)) { /* pending_queue 不為空的情況,清空 I/O 回調。返回 1  */     q = QUEUE_HEAD(&pq);     QUEUE_REMOVE(q);     QUEUE_INIT(q);     w = QUEUE_DATA(q, uv__io_t, pending_queue);     w->cb(loop, w, POLLOUT);   }   return 1; }
        • 如果存放 I/O 回調的任務的 pending_queue 是空的,那么直接返回 0。
        • 如果 pending_queue 有 I/O 回調任務,那么執行回調任務。

        6 idle, prepare 階段

        idle 做一些 libuv 一些內部操作, prepare 為接下來的 I/O 輪詢做一些準備工作。接下來一起解析一下比較重要 poll 階段。

        7 poll I / O 輪詢階段

        在正式講解 poll 階段做哪些事情之前,首先看一下,在 libuv 中,輪詢階段的執行邏輯:

          timeout = 0;     if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)       /* 計算 timeout   */       timeout = uv_backend_timeout(loop);       /* 進入 I/O 輪詢 */       uv__io_poll(loop, timeout);
        • 初始化超時時間 timeout = 0 ,通過 uv_backend_timeout 計算本次 poll 階段的超時時間。超時時間會影響到異步 I/O 和后續事件循環的執行。

        timeout代表什么

        首先要明白不同 timeout ,在 I/O 輪詢中代表什么意思。

        • timeout = 0 的時候,說明 poll 階段不會阻塞事件循環的進行,那么說明有更迫切執行的任務。那么當前的 poll 階段不會發生阻塞,會盡快進入下一階段,盡快結束當前 tick,進入下一次事件循環,那么這些緊急任務將被執行。
        • timeout = -1時,說明會一直阻塞事件循環,那么此時就可以停留在異步 I/O 的 poll 階段,等待新的 I/O 任務完成。
        • timeout等于常數的情況,說明此時 io poll 循環階段能夠停留的時間,那么什么時候會存在 timeout 為常數呢,將馬上揭曉。

        獲取timeout

        timeout 的獲取是通過 uv_backend_timeout 那么如何獲得的呢?

        int uv_backend_timeout(const uv_loop_t* loop) {     /* 當前事件循環任務停止 ,不阻塞 */   if (loop->stop_flag != 0)     return 0;    /* 當前事件循環 loop 不活躍的時候 ,不阻塞 */   if (!uv__has_active_handles(loop) && !uv__has_active_reqs(loop))     return 0;   /* 當 idle 句柄隊列不為空時,返回 0,即不阻塞。 */   if (!QUEUE_EMPTY(&loop->idle_handles))     return 0;    /* i/o pending 隊列不為空的時候。 */     if (!QUEUE_EMPTY(&loop->pending_queue))     return 0;    /* 有關閉回調 */   if (loop->closing_handles)     return 0;   /* 計算有沒有延時最小的延時器 | 定時器 */   return uv__next_timeout(loop); }

        uv_backend_timeout 主要做的事情是:

        • 當前事件循環停止時,不阻塞。
        • 當前事件循環 loop 不活躍的時候 ,不阻塞。
        • 當 idle 隊列 ( setImmediate ) 不為空時,返回 0,不阻塞。
        • i/o pending 隊列不為空的時候,不阻塞。
        • 有關閉回調函數的時候,不阻塞。
        • 如果上述均不滿足,那么通過 uv__next_timeout 計算有沒有延時閥值最小的定時器 | 延時器( 最急迫執行 ),返回延時時間。

        接下來看一下 uv__next_timeout 邏輯。

        int uv__next_timeout(const uv_loop_t* loop) {   const struct heap_node* heap_node;   const uv_timer_t* handle;   uint64_t diff;   /* 找到延時時間最小的 timer  */   heap_node = heap_min((const struct heap*) &loop->timer_heap);   if (heap_node == NULL) /* 如何沒有 timer,那么返回 -1 ,一直進入 poll 狀態  */     return -1;     handle = container_of(heap_node, uv_timer_t, heap_node);    /* 有過期的 timer 任務,那么返回 0,poll 階段不阻塞 */   if (handle->timeout <= loop->time)     return 0;   /* 返回當前最小閥值的 timer 與 當前事件循環的事件相減,得出來的時間,可以證明 poll 可以停留多長時間 */    diff = handle->timeout - loop->time;   return (int) diff; }

        uv__next_timeout 做的事情如下:

        • 找到時間閥值最小的 timer (最優先執行的),如何沒有 timer,那么返回 -1 。poll 階段將無限制阻塞。這樣的好處是一旦有 I/O 執行完畢 ,I/O 回調函數會直接加入到 poll ,接下來就會執行對應的回調函數。
        • 如果有 timer ,但是 timeout <= loop.time 證明已經過期了,那么返回 0,poll 階段不阻塞,優先執行過期任務。
        • 如果沒有過期,返回當前最小閥值的 timer 與 當前事件循環的事件相減得值,即是可以證明 poll 可以停留多長時間。當停留完畢,證明有過期 timer ,那么進入到下一個 tick。

        執行io_poll

        接下來就是 uv__io_poll 真正的執行,里面有一個 epoll_wait 方法,根據 timeout ,來輪詢有沒有 I/O 完成,有得話那么執行 I/O 回調。這也是 unix 下異步I/O 實現的重要環節。

        poll階段本質

        接下來總結一下 poll 階段的本質:

        • poll 階段就是通過 timeout 來判斷,是否阻塞事件循環。poll 也是一種輪詢,輪詢的是 i/o 任務,事件循環傾向于 poll 階段的持續進行,其目的就是更快的執行 I/O 任務。如果沒有其他任務,那么將一直處于 poll 階段。
        • 如果有其他階段更緊急待執行的任務,比如 timer ,close ,那么 poll 階段將不阻塞,會進行下一個 tick 階段。

        poll 階段流程圖

        我把整個 poll 階段做的事用流程圖表示,省去了一些細枝末節。

        Nodejs進階學習:深入了解異步I/O和事件循環

        8 check 階段

        如果 poll 階段進入 idle 狀態并且 setImmediate 函數存在回調函數時,那么 poll 階段將打破無限制的等待狀態,并進入 check 階段執行 check 階段的回調函數。

        check 做的事就是處理 setImmediate 回調。,先來看一下 Nodejs 中是怎么定義的 setImmediate。

        Nodejs 底層中的 setImmediate

        setImmediate定義

        node/lib/timer.js

        function setImmediate(callback, arg1, arg2, arg3) {   validateCallback(callback); /* 校驗一下回調函數 */    /* 創建一個 Immediate 類   */    return new Immediate(callback, args); }
        • 當調用 setImmediate 本質上調用 nodejs 中的 setImmediate 方法,首先校驗回調函數,然后創建一個 Immediate 類。接下來看一下 Immediate 類。

        node/lib/internal/timers.js

        class Immediate{    constructor(callback, args) {     this._idleNext = null;     this._idlePrev = null; /* 初始化參數 */     this._onImmediate = callback;     this._argv = args;     this._destroyed = false;     this[kRefed] = false;      initAsyncResource(this, 'Immediate');     this.ref();     immediateInfo[kCount]++;          immediateQueue.append(this); /* 添加 */   } }
        • Immediate 類會初始化一些參數,然后將當前 Immediate 類,插入到 immediateQueue 鏈表中。
        • immediateQueue 本質上是一個鏈表,存放每一個 Immediate。

        setImmediate執行

        poll 階段之后,會馬上到 check 階段,執行 immediateQueue 里面的 Immediate。 在每一次事件循環中,會先執行一個setImmediate 回調,然后清空 nextTick 和 Promise 隊列的內容。為了驗證這個結論,同樣和 setTimeout 一樣,看一下如下代碼塊:

        setImmediate(()=>{     console.log('setImmediate1')     process.nextTick(()=>{         console.log('nextTick')     }) })  setImmediate(()=>{     console.log('setImmediate2') })

        Nodejs進階學習:深入了解異步I/O和事件循環

        打印 setImmediate1 -> nextTick -> setImmediate2 ,在每一次事件循環中,執行一個 setImmediate ,然后執行清空 nextTick 隊列,在下一次事件循環中,執行另外一個 setImmediate2 。

        setImmediate執行流程圖

        Nodejs進階學習:深入了解異步I/O和事件循環

        setTimeout & setImmediate

        接下來對比一下 setTimeoutsetImmediate,如果開發者期望延時執行的異步任務,那么接下來對比一下 setTimeout(fn,0)setImmediate(fn) 區別。

        • setTimeout 是 用于在設定閥值的最小誤差內,執行回調函數,setTimeout 存在精度問題,創建 setTimeout 和 poll 階段都可能影響到 setTimeout 回調函數的執行。
        • setImmediate 在 poll 階段之后,會馬上進入 check 階段,會執行 setImmediate回調。

        如果 setTimeout 和 setImmediate 在一起,那么誰先執行呢?

        首先寫一個 demo:

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

        猜測

        先猜測一下,setTimeout 發生 timer 階段,setImmediate 發生在 check 階段,timer 階段早于 check 階段,那么 setTimeout 優先于 setImmediate 打印。但事實是這樣嗎?

        實際打印結果

        Nodejs進階學習:深入了解異步I/O和事件循環

        從以上打印結果上看, setTimeoutsetImmediate 執行時機是不確定的,為什么會造成這種情況,上文中講到即使 setTimeout 第二個參數為 0,在 nodejs 中也會被處理 setTimeout(fn,1)。當主進程的同步代碼執行之后,會進入到事件循環階段,第一次進入 timer 中,此時 settimeout 對應的 timer 的時間閥值為 1,若在前文 uv__run_timer(loop) 中,系統時間調用和時間比較的過程總耗時沒有超過 1ms 的話,在 timer 階段會發現沒有過期的計時器,那么當前 timer 就不會執行,接下來到 check 階段,就會執行 setImmediate 回調,此時的執行順序是: setImmediate -> setTimeout。

        但是如果總耗時超過一毫秒的話,執行順序就會發生變化,在 timer 階段,取出過期的 setTimeout 任務執行,然后到 check 階段,再執行 setImmediate ,此時 setTimeout -> setImmediate

        造成這種情況發生的原因是:timer 的時間檢查距當前事件循環 tick 的間隔可能小于 1ms 也可能大于 1ms 的閾值,所以決定了 setTimeout 在第一次事件循環執行與否。

        接下來我用代碼阻塞的情況,會大概率造成 setTimeout 一直優先于 setImmediate 執行。

        /* TODO:  setTimeout & setImmediate */ setImmediate(()=>{     console.log( 'setImmediate' ) })  setTimeout(()=>{     console.log('setTimeout') },0) /* 用 100000 循環阻塞代碼,促使 setTimeout 過期 */ for(let i=0;i<100000;i++){ }

        效果:

        Nodejs進階學習:深入了解異步I/O和事件循環

        100000 循環阻塞代碼,這樣會讓 setTimeout 超過時間閥值執行,這樣就保證了每次先執行 setTimeout -> setImmediate 。

        特殊情況:確定順序一致性。我們看一下特殊的情況。

        const fs = require('fs') fs.readFile('./file.js',()=>{     setImmediate(()=>{         console.log( 'setImmediate' )     })     setTimeout(()=>{         console.log('setTimeout')     },0) })

        如上情況就會造成,setImmediate 一直優先于 setTimeout 執行,至于為什么,來一起分析一下原因。

        • 首先分析一下異步任務——主進程中有一個異步 I/O 任務,I/O 回調中有一個 setImmediate 和 一個 setTimeout 。
        • poll 階段會執行 I/O 回調。然后處理一個 setImmediate

        萬變不離其宗,只要掌握了如上各個階段的特性,那么對于不同情況的執行情況,就可以清晰的分辨出來。

        9 close 階段

        close 階段用于執行一些關閉的回調函數。執行所有的 close 事件。接下來看一下 close 事件 libuv 的實現。

        libuv/src/unix/core.c

        static void uv__run_closing_handles(uv_loop_t* loop) {   uv_handle_t* p;   uv_handle_t* q;    p = loop->closing_handles;   loop->closing_handles = NULL;    while (p) {     q = p->next_closing;     uv__finish_close(p);     p = q;   } }
        • uv__run_closing_handles 這個方法循環執行 close 隊列里面的回調函數。

        10 Nodejs 事件循環總結

        接下來總結一下 Nodejs 事件循環。

        • Nodejs 的事件循環分為 6 大階段。分別為 timer 階段,pending 階段,prepare 階段,poll 階段, check 階段,close 階段。

        • nextTick 隊列和 Microtasks 隊列執行特點,在每一階段完成后執行, nextTick 優先級大于 Microtasks ( Promise )。

        • poll 階段主要處理 I/O,如果沒有其他任務,會處于輪詢阻塞階段。

        • timer 階段主要處理定時器/延時器,它們并非準確的,而且創建需要額外的性能浪費,它們的執行還收到 poll 階段的影響。

        • pending 階段處理 I/O 過期的回調任務。

        • check 階段處理 setImmediate。 setImmediate 和 setTimeout 執行時機和區別。

        Nodejs事件循環習題演練

        接下來為了更清楚事件循環流程,這里出兩道事件循環的問題。作為實踐:

        習題一

        process.nextTick(function(){     console.log('1'); }); process.nextTick(function(){     console.log('2');      setImmediate(function(){         console.log('3');     });     process.nextTick(function(){         console.log('4');     }); });  setImmediate(function(){     console.log('5');      process.nextTick(function(){         console.log('6');     });     setImmediate(function(){         console.log('7');     }); });  setTimeout(e=>{     console.log(8);     new Promise((resolve,reject)=>{         console.log(8+'promise');         resolve();     }).then(e=>{         console.log(8+'promise+then');     }) },0)  setTimeout(e=>{ console.log(9); },0)  setImmediate(function(){     console.log('10');     process.nextTick(function(){         console.log('11');     });     process.nextTick(function(){         console.log('12');     });     setImmediate(function(){         console.log('13');     }); });  console.log('14');  new Promise((resolve,reject)=>{     console.log(15);     resolve(); }).then(e=>{     console.log(16); })

        如果剛看這個 demo 可以會發蒙,不過上述講到了整個事件循環,再來看這個問題就很輕松了,下面來分析一下整體流程:

        • 第一階段: 首先開始啟動 js 文件,那么進入第一次事件循環,那么先會執行同步任務:

        最先打?。?/p>

        打印console.log('14');

        打印console.log(15);

        nextTick 隊列:

        nextTick -> console.log(1) nextTick -> console.log(2) -> setImmediate(3) -> nextTick(4)

        Promise隊列

        Promise.then(16)

        check隊列

        setImmediate(5) -> nextTick(6) -> setImmediate(7) setImmediate(10) -> nextTick(11) -> nextTick(12) -> setImmediate(13)

        timer隊列

        setTimeout(8) -> promise(8+'promise') -> promise.then(8+'promise+then') setTimeout(9)

        • 第二階段:在進入新的事件循環之前,清空 nextTick 隊列,和 promise 隊列,順序是 nextTick 隊列大于 Promise 隊列。

        清空 nextTick ,打?。?/p>

        console.log('1');

        console.log('2');

        執行第二個 nextTick 的時候,又有一個 nextTick ,所以會把這個 nextTick 也加入到隊列中。接下來馬上執行。

        console.log('4')

        接下來清空Microtasks

        console.log(16);

        此時的 check 隊列加入了新的 setImmediate。

        check隊列setImmediate(5) -> nextTick(6) -> setImmediate(7) setImmediate(10) -> nextTick(11) -> nextTick(12) -> setImmediate(13) setImmediate(3)

        • 然后進入新的事件循環,首先執行 timer 里面的任務。執行第一個 setTimeout。

        執行第一個 timer:

        console.log(8);

        此時發現一個 Promise 。在正常的執行上下文中:

        console.log(8+'promise');

        然后將 Promise.then 加入到 nextTick 隊列中。接下里會馬上清空 nextTick 隊列。

        console.log(8+'promise+then');

        執行第二個 timer:

        console.log(9)

        • 接下來到了 check 階段,執行 check 隊列里面的內容:

        執行第一個 check:

        console.log(5);

        此時發現一個 nextTick ,然后還有一個 setImmediate 將 setImmediate 加入到 check 隊列中。然后執行 nextTick 。

        console.log(6)

        執行第二個 check

        console.log(10)

        此時發現兩個 nextTick 和一個 setImmediate 。接下來清空 nextTick 隊列。將 setImmediate 添加到隊列中。

        console.log(11)

        console.log(12)

        此時的 check 隊列是這樣的:

        setImmediate(3) setImmediate(7) setImmediate(13)

        接下來按順序清空 check 隊列。打印

        console.log(3)

        console.log(7)

        console.log(13)

        到此為止,執行整個事件循環。那么整體打印內容如下:

        Nodejs進階學習:深入了解異步I/O和事件循環

        總結

        本文主要講的內容如下:

        • 異步 I/O 介紹及其內部原理。
        • Nodejs 的事件循環,六大階段。
        • Nodejs 中 setTimeout ,setImmediate , 異步 i/o ,nextTick ,Promise 的原理及其區別。
        • Nodejs 事件循環實踐。

        原文地址:https://juejin.cn/post/7002106372200333319

        作者:我不是外星人

        贊(0)
        分享到: 更多 (0)
        網站地圖   滬ICP備18035694號-2    滬公網安備31011702889846號
        主站蜘蛛池模板: 四库影院永久四虎精品国产 | 国产精品igao视频网| 国产精品免费视频观看拍拍| 国产精品毛片VA一区二区三区| 亚洲AV无码乱码精品国产 | 国内精品久久久久久久97牛牛| 国产精品一区12p| 亚洲视频精品在线| 国产成人精品优优av| 亚洲一区精品无码| 久久99精品久久久久久噜噜| 在线观看91精品国产入口| 国产91久久精品一区二区| 久久精品国产亚洲AV高清热| 最新国产精品精品视频| 久久精品国产亚洲一区二区三区 | 国产精品久久久天天影视| 久久久无码人妻精品无码| 亚洲精品一级无码中文字幕| 青青草97国产精品免费观看| 久久久WWW免费人成精品| 国自产精品手机在线观看视频| 99热精品毛片全部国产无缓冲| 九九热精品在线| 久久99国产精品99久久| 91久久精品91久久性色| 国产精品99久久免费观看| 国产日韩精品中文字无码| 久久国产欧美日韩精品| 久久香蕉超碰97国产精品| 久久香蕉国产线看观看精品yw| 久久精品国产亚洲AV大全| 精品一区二区三区免费毛片爱| 亚洲AV无码成人精品区天堂 | 99久久精品无码一区二区毛片| 欧美日韩精品一区二区| 亚洲精品福利视频| 亚洲嫩草影院久久精品| 丰满人妻熟妇乱又伦精品劲| 国产精品欧美久久久久天天影视 | 2022精品国偷自产免费观看|