JS代碼在nodejs環境下執行機制和事件循環

1. 說明

nodejs是單線程執行的,同時它又是基於事件驅動的非阻塞IO編程模型。這就使得我們不用等待異步操作結果返回,就可以繼續往下執行代碼。當異步事件觸發之後,就會通知主線程,主線程執行相應事件的回調。

本篇文章講解node中JavaScript的代碼的執行流程,下面是測試代碼,如果你知道輸出的結果,那麼就不需要再看本篇文章,如果不知道輸出結果,那麼本片文章可幫助你瞭解:

console.log(1)
setTimeout(function () {
  new Promise(function (resolve) {
    console.log(2)
    resolve()
  })
  .then(() => { console.log(3) })
})
setTimeout(function () {
  console.log(4)
})

複雜的:

setTimeout(() => {
  console.log('1')
  new Promise((resolve) => { console.log('2'); resolve(); })
  .then(() => { console.log('3') })
  new Promise((resolve)=> { console.log('4'); resolve()})
  .then(() => { console.log('5') })
  setTimeout(() => { 
    console.log('6')
    setTimeout(() => {
      console.log('7')
      new Promise((resolve) => { console.log('8'); resolve() })
      .then( () => {  console.log('9') })
      new Promise((resolve) => { console.log('10'); resolve() })
      .then(() => {  console.log('11') })
    })
    setTimeout(() => { console.log('12') }, 0)
  })
  setTimeout(() => { console.log('13') }, 0)
})
setTimeout(() => { console.log('14') }, 0)
new Promise((resolve) => { console.log('15'); resolve() })
.then( ()=> { console.log('16') })
new Promise((resolve) => { console.log('17'); resolve() })
.then(() => { console.log('18') })

2. nodejs的啓動過程

node.js啓動過程可以分爲以下步驟:

  1. 調用platformInit方法 ,初始化 nodejs 的運行環境。
  2. 調用 performance_node_start 方法,對 nodejs 進行性能統計。
  3. openssl設置的判斷。
  4. 調用v8_platform.Initialize,初始化 libuv 線程池。
  5. 調用 V8::Initialize,初始化 V8 環境。
  6. 創建一個nodejs運行實例。
  7. 啓動上一步創建好的實例。
  8. 開始執行js文件,同步代碼執行完畢後,進入事件循環。
  9. 在沒有任何可監聽的事件時,銷燬 nodejs 實例,程序執行完畢。

clipboard.png

3. nodejs的事件循環詳解

Nodejs 將消息循環又細分爲 6 個階段(官方叫做 Phase), 每個階段都會有一個類似於隊列的結構, 存儲着該階段需要處理的回調函數.

Nodejs 爲了防止某個 階段 任務太多, 導致後續的 階段 發生飢餓的現象, 所以消息循環的每一個迭代(iterate) 中, 每個 階段 執行回調都有個最大數量. 如果超過數量的話也會強行結束當前 階段而進入下一個 階段. 這一條規則適用於消息循環中的每一個 階段.

3.1 Timer 階段

這是消息循環的第一個階段, 用一個 for 循環處理所有 setTimeoutsetInterval 的回調.

這些回調被保存在一個最小堆(min heap) 中. 這樣引擎只需要每次判斷頭元素, 如果符合條件就拿出來執行, 直到遇到一個不符合條件或者隊列空了, 才結束 Timer Phase.

Timer 階段中判斷某個回調是否符合條件的方法也很簡單. 消息循環每次進入 Timer 的時候都會保存一下當時的系統時間,然後只要看上述最小堆中的回調函數設置的啓動時間是否超過進入 Timer 時保存的時間, 如果超過就拿出來執行.

3.2 Pending I/O Callback 階段

執行除了close callbackssetTimeout()setInterval()setImmediate()回調之外幾乎所有回調,比如說TCP連接發生錯誤fs.read, socket 等 IO 操作的回調函數, 同時也包括各種 error 的回調.

3.3 Idle, Prepare 階段

系統內部的一些調用。

3.4 Poll 階段,重要階段

這是整個消息循環中最重要的一個 階段, 作用是等待異步請求和數據,因爲它支撐了整個消息循環機制.

poll階段有兩個主要的功能:一是執行下限時間已經達到的timers的回調,一是處理poll隊列裏的事件。
注:Node的很多API都是基於事件訂閱完成的,比如fs.readFile,這些回調應該都在poll階段完成。

當事件循環進入poll階段:

  • poll隊列不爲空的時候,事件循環肯定是先遍歷隊列並同步執行回調,直到隊列清空或執行回調數達到系統上限。
  • poll隊列爲空的時候,這裏有兩種情況。

    • 如果代碼已經被setImmediate()設定了回調,那麼事件循環直接結束poll階段進入check階段來執行check隊列裏的回調。
    • 如果代碼沒有被設定setImmediate()設定回調:

      • 如果有被設定的timers,那麼此時事件循環會檢查timers,如果有一個或多個timers下限時間已經到達,那麼事件循環將繞回timers階段,並執行timers的有效回調隊列。
      • 如果沒有被設定timers,這個時候事件循環是阻塞在poll階段等待事件回調被加入poll隊列。

Poll階段,當js層代碼註冊的事件回調都沒有返回的時候,事件循環會暫時阻塞在poll階段,解除阻塞的條件:

  1. 在poll階段執行的時候,會傳入一個timeout超時時間,該超時時間就是poll階段的最大阻塞時間。
  2. timeout時間未到的時候,如果有事件返回,就執行該事件註冊的回調函數。timeout超時時間到了,則退出poll階段,執行下一個階段。

這個 timeout 設置爲多少合適呢? 答案就是 Timer Phase 中最近要執行的回調啓動時間到現在的差值, 假設這個差值是 detal. 因爲 Poll Phase 後面沒有等待執行的回調了. 所以這裏最多等待 delta 時長, 如果期間有事件喚醒了消息循環, 那麼就繼續下一個 Phase 的工作; 如果期間什麼都沒發生, 那麼到了 timeout 後, 消息循環依然要進入後面的 Phase, 讓下一個迭代的 Timer Phase 也能夠得到執行.
Nodejs 就是通過 Poll Phase, 對 IO 事件的等待和內核異步事件的到達來驅動整個消息循環的.

3.5 Check 階段

這個階段只處理 setImmediate 的回調函數.
那麼爲什麼這裏要有專門一個處理 setImmediate 的 階段 呢? 簡單來說, 是因爲 Poll 階段可能設置一些回調, 希望在 Poll 階段 後運行. 所以在 Poll 階段 後面增加了這個 Check 階段.

3.6 Close Callbacks 階段

專門處理一些 close 類型的回調. 比如 socket.on('close', ...). 用於資源清理.

4. nodejs執行JS代碼過程及事件循環過程

  • 1、node初始化

    • 初始化node環境
    • 執行輸入的代碼
    • 執行process.nextTick回調
    • 執行微任務(microtasks)
  • 2、進入事件循環

    • 2.1、進入Timer階段

      • 檢查Timer隊列是否有到期的Timer的回調,如果有,將到期的所有Timer回調按照TimerId升序執行
      • 檢查是否有process.nextTick任務,如果有,全部執行
      • 檢查是否有微任務(promise),如果有,全部執行
      • 退出該階段
    • 2.2、進入Pending I/O Callback階段

      • 檢查是否有Pending I/O Callback的回調,如果有,執行回調。如果沒有退出該階段
      • 檢查是否有process.nextTick任務,如果有,全部執行
      • 檢查是否有微任務(promise),如果有,全部執行
      • 退出該階段
    • 2.3、進入idle,prepare階段

      這個階段與JavaScript關係不大,略過

    • 2.4、進入Poll階段

      • 首先檢查是否存在尚未完成的回調,如果存在,分如下兩種情況:

        • 第一種情況:有可執行的回調

          • 執行所有可用回調(包含到期的定時器還有一些IO事件等)
          • 檢查是否有process.nextTick任務,如果有,全部執行
          • 檢查是否有微任務(promise),如果有,全部執行
          • 退出該階段
        • 第二種情況:沒有可執行的回調

          • 檢查是否有immediate回調,如果有,退出Poll階段。如果沒有,阻塞在此階段,等待新的事件通知
      • 如果不存在尚未完成的回調,退出Poll階段
    • 2.5、進入check階段

      • 如果有immediate回調,則執行所有immediate回調
      • 檢查是否有process.nextTick任務,如果有,全部執行
      • 檢查是否有微任務(promise),如果有,全部執行
      • 退出該階段
    • 2.6、進入closing階段

      • 如果有immediate回調,則執行所有immediate回調
      • 檢查是否有process.nextTick任務,如果有,全部執行
      • 檢查是否有微任務(promise),如果有,全部執行
      • 退出該階段
  • 3、檢查是否有活躍的handles(定時器、IO等事件句柄)

    • 如果有,繼續下一輪事件循環
    • 如果沒有,結束事件循環,退出程序

注意:

事件循環的每一個子階段退出之前都會按順序執行如下過程:

  • 檢查是否有 process.nextTick 回調,如果有,全部執行。
  • 檢查是否有 微任務(promise),如果有,全部執行。

4.1 關於Promise和process.nextTick

事件循環隊列先保證所有的process.nextTick回調,然後將所有的Promise回調追加在後面,最終在每個階段結束的時候一次性拿出來執行。

此外,process.nextTickPromise回調的數量是受限制的,也就是說,如果一直往這個隊列中加入回調,那麼整個事件循環就會被卡住

clipboard.png

4.2 關於setTimeout(…, 0) 和 setImmediate

這兩個方法的回調到底誰快?

如下面的例子:

setImmediate(() => console.log(2))
setTimeout(() => console.log(1))

使用nodejs多次執行後,發現輸出結果有時是1 2,有時是2 1

對於多次執行輸出結果不同,需要了解事件循環的基礎問題。

首先,Nodejs啓動,初始化環境後加載我們的JS代碼(index.js).發生了兩件事(此時尚未進入消息循環環節):

setImmediate 向 Check 階段 中添加了回調 console.log(2);

setTimeout 向 Timer 階段 中添加了回調 console.log(1)

這時候, 要初始化階段完畢, 要進入 Nodejs 消息循環了。

爲什麼會有兩種輸出呢? 接下來一步很關鍵:

當執行到 Timer 階段 時, 會發生兩種可能. 因爲每一輪迭代剛剛進入 Timer 階段 時會取系統時間保存起來, 以 ms(毫秒) 爲最小單位.

如果 Timer 階段 中回調預設的時間 > 消息循環所保存的時間, 則執行 Timer 階段 中的該回調. 這種情況下先輸出 1, 直到 Check 階段 執行後,輸出2.總的來說, 結果是 1 2.

如果運行比較快, Timer 階段 中回調預設的時間可能剛好等於消息循環所保存的時間, 這種情況下, Timer 階段 中的回調得不到執行, 則繼續下一個 階段. 直到 Check 階段, 輸出 2. 然後等下一輪迭代的 Timer 階段, 這時的時間一定是滿足 Timer 階段 中回調預設的時間 > 消息循環所保存的時間 , 所以 console.log(1) 得到執行, 輸出 1. 總的來說, 結果就是 2 1.

所以, 輸出不穩定的原因就取決於進入 Timer 階段 的時間是否和執行 setTimeout 的時間在 1ms 內. 如果把代碼改成如下, 則一定會得到穩定的輸出:

require('fs').readFile('my-file-path.txt', () => {
 setImmediate(() => console.log(2))
 setTimeout(() => console.log(1))
});

這是因爲消息循環在 Pneding I/O Phase 才向 Timer 和 Check 隊列插入回調. 這時按照消息循環的執行順序, Check 一定在 Timer 之前執行。

從性能角度講, setTimeout 的處理是在 Timer Phase, 其中 min heap 保存了 timer 的回調, 因此每執行一個回調的同時都會涉及到堆調整. 而 setImmediate 僅僅是清空一個隊列. 效率自然會高很多.

再從執行時機上講. setTimeout(..., 0) 和 setImmediate 完全屬於兩個階段.

5. 一個實際例子演示

下面以一段代碼來說明nodejs運行JavaScript的機制。

如下面一段代碼:

setTimeout(() => {                                                // settimeout1
  console.log('1')
  new Promise((resolve) => { console.log('2'); resolve(); })      // Promise3
  .then(() => { console.log('3') })
  new Promise((resolve)=> { console.log('4'); resolve()})         // Promise4
  .then(() => { console.log('5') })
  setTimeout(() => {                                              // settimeout3
    console.log('6')
    setTimeout(() => {                                            // settimeout5
      console.log('7')
      new Promise((resolve) => { console.log('8'); resolve() })   // Promise5
      .then( () => {  console.log('9') })
      new Promise((resolve) => { console.log('10'); resolve() })  // Promise6
      .then(() => {  console.log('11') })
    })
    setTimeout(() => { console.log('12') }, 0)                    // settimeout6
  })
  setTimeout(() => { console.log('13') }, 0)                      // settimeout4
})
setTimeout(() => { console.log('14') }, 0)                        // settimeout2
new Promise((resolve) => { console.log('15'); resolve() })        // Promise1
.then( ()=> { console.log('16') })
new Promise((resolve) => { console.log('17'); resolve() })        // Promise2
.then(() => { console.log('18') })

上面代碼執行過程:

  • node初始化

    • 執行JavaScript代碼

      • 遇到setTimeout, 把回調函數放到Timer隊列中,記爲settimeout1
      • 遇到setTimeout, 把回調函數放到Timer隊列中,記爲settimeout2
      • 遇到Promise,執行,輸出15,把回調函數放到微任務隊列,記爲Promise1
      • 遇到Promise,執行,輸出17,把回調函數放到微任務隊列,記爲Promise2
      • 代碼執行結束,此階段輸出結果:15 17
    • 沒有process.nextTick回調,略過
    • 執行微任務

      • 檢查微任務隊列是否有可執行回調,此時隊列有2個回調:Promise1、Promise2
      • 執行Promise1回調,輸出16
      • 執行Promise2回調,輸出18
      • 此階段輸出結果:16 18
  • 進入第一次事件循環

    • 進入Timer階段

      • 檢查Timer隊列是否有可執行的回調,此時隊列有2個回調:settimeout1、settimeout2
      • 執行settimeout1回調:

        • 輸出1、2、4
        • 添加了2個微任務,記爲Promise3、Promise4
        • 添加了2個Timer任務,記爲settimeout3、settimeout4
      • 執行settimeout2回調,輸出14
      • Timer隊列任務執行完畢
      • 沒有process.nextTick回調,略過
      • 檢查微任務隊列是否有可執行回調,此時隊列有2個回調:Promise3、Promise4
      • 按順序執行2個微任務,輸出3、5
      • 此階段輸出結果:1 2 4 14 3 5
    • Pending I/O Callback階段沒有任務,略過
    • 進入 Poll 階段

      • 檢查是否存在尚未完成的回調,此時有2個回調:settimeout3、settimeout4
      • 執行settimeout3回調

        • 輸出6
        • 添加了2個Timer任務,記爲settimeout5、settimeout6
      • 執行settimeout4回調,輸出13
      • 沒有process.nextTick回調,略過
      • 沒有微任務,略過
      • 此階段輸出結果:6 13
    • check、closing階段沒有任務,略過
    • 檢查是否還有活躍的handles(定時器、IO等事件句柄),有,繼續下一輪事件循環
  • 進入第二次事件循環

    • 進入Timer階段

      • 檢查Timer隊列是否有可執行的回調,此時隊列有2個回調:settimeout5、settimeout6
      • 執行settimeout5回調:

        • 輸出7、 8、10
        • 添加了2個微任務,記爲Promise5、Promise6
      • 執行settimeout6回調,輸出12
      • 沒有process.nextTick回調,略過
      • 檢查微任務隊列是否有可執行回調,此時隊列有2個回調:Promise5、Promise6
      • 按順序執行2個微任務,輸出9、11
      • 此階段輸出結果:7 8 10 12 9 11
    • Pending I/O Callback、Poll、check、closing階段沒有任務,略過
    • 檢查是否還有活躍的handles(定時器、IO等事件句柄),沒有了,結束事件循環,退出程序
  • 程序執行結束,輸出結果:15 17 16 18 1 2 4 14 3 5 6 13 7 8 10 12 9 11

clipboard.png

參考資料

深入分析Node.js事件循環與消息隊列

剖析nodejs的事件循環

Node中的事件循環和異步API

Node.js Event Loop nodejs官網

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章