Nodejs進階學習:深入瞭解非同步I/O和事件迴圈

2021-09-23 22:00:38
本篇文章是Nodejs的進階學習,帶大家詳細瞭解一下中的非同步I/O和事件迴圈,希望對大家有所幫助!

本文講詳細講解 nodejs 中兩個比較難以理解的部分非同步I/O事件迴圈,對 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 在整個呼叫棧上示意圖如下:

1.png

非同步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 模型中會這樣。

2.png

接下來將一起探索一下非同步 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 執行模型。

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

3.png

總結上述過程:

  • 第一階段:每一次非同步 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 的事件迴圈為參考,關係如下:

4.png

以瀏覽器下 javaScript 的事件迴圈為參考,關係如下:

5.png

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

const queue = [ ... ]   // queue 裡面放著待處理事件
while(true){
    //開始迴圈
    //執行 queue 中的任務
    //....

    if(queue.length ===0){
       return // 退出程序
    }
}
  • Nodejs 啟動後,就像建立一個 while 迴圈一樣,queue 裡面放著待處理的事件,每一次迴圈過程中,如果還有事件,那麼取出事件,執行事件,如果存在事件關聯的回撥函數,那麼執行回撥函數,然後開始下一次迴圈。
  • 如果迴圈體中沒有事件,那麼將退出程序。

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

6.png

那麼如何事件迴圈是如何處理這些任務的呢?我們列出 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 中的執行順序是什麼?

效果:

7.png

列印結果: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);

效果:

8.gif

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

3 事件迴圈流程圖

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

9.png

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 在事件迴圈裡面執行的流程。

10.png

timer 特性

這裡有兩點需要注意:

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

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

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

列印結果:

11.png

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 階段做的事用流程圖表示,省去了一些細枝末節。

12.png

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')
})

13.png

列印 setImmediate1 -> nextTick -> setImmediate2 ,在每一次事件迴圈中,執行一個 setImmediate ,然後執行清空 nextTick 佇列,在下一次事件迴圈中,執行另外一個 setImmediate2 。

setImmediate執行流程圖

14.png

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 列印。但事實是這樣嗎?

實際列印結果

15.png

從以上列印結果上看, 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++){
}

效果:

16.png

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 檔案,那麼進入第一次事件迴圈,那麼先會執行同步任務:

最先列印:

列印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 ,列印:

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)

到此為止,執行整個事件迴圈。那麼整體列印內容如下:

17.png

總結

本文主要講的內容如下:

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

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

作者:我不是外星人

更多程式設計相關知識,請存取:!!

以上就是Nodejs進階學習:深入瞭解非同步I/O和事件迴圈的詳細內容,更多請關注TW511.COM其它相關文章!