成為自信的node.js 開發者(二)

杜俊成要好好學習發表於2019-02-18

這一章,我們來學習一下event_loop, 本文內容旨在釐清瀏覽器(browsing context)和Node環境中不同的 Event Loop。

首先清楚一點:瀏覽器環境和 node環境的event-loop 完全不一樣。

瀏覽器環境

為了協調事件、使用者互動、指令碼、UI渲染、網路請求等行為,使用者引擎必須使用Event Loopevent loop包含兩類:基於browsing contexts,基於worker。

本文討論的瀏覽器中的EL基於browsing contexts

成為自信的node.js 開發者(二)

上面圖中,關鍵性的兩點:

同步任務直接進入主執行棧(call stack)中執行

等待主執行棧中任務執行完畢,由EL將非同步任務推入主執行棧中執行

task——巨集任務

task在網上也被成為macrotask (巨集任務)

巨集任務分類:

script程式碼

setTimeout/setInterval

setImmediate (未實現)

I/O

UI互動

巨集任務特徵

一個event loop 中,有一個或多個 task佇列。

不同的task會放入不同的task佇列中:比如,瀏覽器會為滑鼠鍵盤事件分配一個task佇列,為其他的事件分配另外的佇列。

先進佇列的先被執行

microtask——微任務

微任務

微任務的分類

通常下面幾種任務被認為是microtask

promise(promisethencatch才是microtask,本身其內部的程式碼並不是)

MutationObserver

process.nextTick(nodejs環境中)

微任務特性

一個EL中只有一個microtask佇列。

event-loop的迴圈過程

一個EL只要存在,就會不斷執行下邊的步驟:

先執行同步程式碼,所有微任務,一個巨集任務,所有微任務(,更新渲染),一個巨集任務,所有微任務(,更新渲染)...... 執行完microtask佇列裡的任務,有可能會渲染更新。在一幀以內的多次dom變動瀏覽器不會立即響應,而是會積攢變動以最高60HZ的頻率更新檢視

例子

setTimeout(() => console.log('setTimeout1'), 0);
setTimeout(() => {
    console.log('setTimeout2');
    Promise.resolve().then(() => {
        console.log('promise3');
        Promise.resolve().then(() => {
            console.log('promise4');
        })
        console.log(5)
    })
    setTimeout(() => console.log('setTimeout4'), 0);
}, 0);
setTimeout(() => console.log('setTimeout3'), 0);
Promise.resolve().then(() => {
    console.log('promise1');
})
複製程式碼

列印出來的結果是 :

promise1
setTimeout1
setTimeout2
'promise3'
5
promise4
setTimeout3
setTimeout4
複製程式碼

另外一個例子:

console.log('script start')

async function async1() {
    await async2()
    console.log('async1 end')
}
async function async2() {
    console.log('async2 end')
}
async1()

setTimeout(function () {
    console.log('setTimeout')
}, 0)

new Promise(resolve => {
    console.log('Promise')
    resolve()
})
    .then(function () {
        console.log('promise1')
        setTimeout(() => {
            console.log('sssss')
        }, 0)
    })
    .then(function () {
        console.log('promise2')
    })

console.log('script end')
複製程式碼

在瀏覽器內輸出結果如下, node內輸出結果不同

'script start'
'async2 end'
'Promise'
'script end'
'async1 end'
'promise1'
'promise2'
'setTimeout'
'sssss'
複製程式碼
  1. await 只是 fn().then() 這些寫法的語法糖,相當於 await 那一行程式碼下面的程式碼都被當成一個微任務,推入到了microtask queue

  2. 成為自信的node.js 開發者(二)

    順序:執行完同步任務,執行微任務佇列中的全部的微任務,執行一個巨集任務,執行全部的微任務

node 環境中

Node中的event-looplibuv庫 實現,js是單執行緒的,會把回撥和任務交給libuv

event loop 首先會在內部維持多個事件佇列,比如 時間佇列、網路佇列等等,而libuv會執行一個相當於 while true的無限迴圈,不斷的檢查各個事件佇列上面是否有需要處理的pending狀態事件,如果有則按順序去觸發佇列裡面儲存的事件,同時由於libuv的事件迴圈每次只會執行一個回撥,從而避免了 競爭的發生

個人理解,它與瀏覽器中的輪詢機制(一個task,所有microtasks;一個task,所有microtasks…)最大的不同是,node輪詢有phase(階段)的概念,不同的任務在不同階段執行,進入下一階段之前執行所有的process.nextTick() 和 所有的microtasks。

階段

成為自信的node.js 開發者(二)

timers階段

在這個階段檢查是否有超時的timer(setTimeout/setInterval),有的話就執行他們的回撥

但timer設定的閾值不是執行回撥的確切時間(只是最短的間隔時間),node核心排程機制和其他的回撥函式會推遲它的執行

由poll階段來控制什麼時候執行timers callbacks
複製程式碼

I/O callback 階段

處理非同步事件的回撥,比如網路I/O,比如檔案讀取I/O,當這些事件報錯的時候,會在 `I/O` callback階段執行
複製程式碼

poll 階段

這裡是最重要的階段,poll階段主要的兩個功能:

   處理poll queue的callbacks
    
   回到timers phase執行timers callbacks(當到達timers指定的時間時)
    

進入poll階段,timer的設定有下面兩種情況:

1.  event loop進入了poll階段, **未設定timer**
    
       poll queue不為空:event loop將同步的執行queue裡的callback,直到清空或執行的callback到達系統上限
        
       poll queue為空
        
           如果有設定` callback`, event loop將結束poll階段進入check階段,並執行check queue (check queue是 setImmediate設定的)
            
           如果程式碼沒有設定setImmediate() callback,event loop將阻塞在該階段等待callbacks加入poll queue
            
2.  event loop進入了 poll階段, **設定了timer**
    
       如果poll進入空閒狀態,event loop將檢查timers,如果有1個或多個timers時間時間已經到達,event loop將回到 timers 階段執行timers queue
        

這裡的邏輯比較複雜,流程可以藉助下面的圖進行理解:

![](https://ws1.sinaimg.cn/large/006tKfTcgy1g0anodoa11j311i0h0t8w.jpg)
複製程式碼

check 階段

一旦poll佇列閒置下來或者是程式碼被`setImmediate`排程,EL會馬上進入check phase
複製程式碼

close callbacks

關閉I/O的動作,比如檔案描述符的關閉,連線斷開等

如果socket突然中斷,close事件會在這個階段被觸發
複製程式碼

成為自信的node.js 開發者(二)

同步的任務執行完,先執行完全部的process.nextTick() 和 全部的微任務佇列,然後執行每一個階段,每個階段執行完畢後,

注意點

setTimeout 和 setImmediate

  1. 呼叫階段不一樣

  2. 不同的io中,執行順序不保證

二者非常相似,區別主要在於呼叫時機不同。

setImmediate 設計在poll階段完成時執行,即check段;

setTimeout 設計在poll階段為空閒時,且設定時間到達後執行,但它在timer階段執行

setTimeout(function timeout () {
  console.log('timeout');
},0);
setImmediate(function immediate () {
  console.log('immediate');
});
複製程式碼

對於以上程式碼來說,setTimeout 可能執行在前,也可能執行在後。 首先 setTimeout(fn, 0) === setTimeout(fn, 1),這是由原始碼決定的。

如果在準備時候花費了大於 1ms 的時間,那麼在 timer 階段就會直接執行 setTimeout 回撥。 如果準備時間花費小於 1ms,那麼就是 setImmediate 回撥先執行了。

也就是說,進入事件迴圈也是需要成本的。有可能進入event loop 時,setTimeout(fn, 1) 還在等待timer中,並沒有被推入到 time 事件佇列,而setImmediate 方法已經被推入到了 check事件佇列 中了。那麼event_loop 按照timei/opollcheckclose 順序執行,先執行immediate 任務。

成為自信的node.js 開發者(二)

也有可能,進入event loop 時,setTimeout(fn, 1) 已經結束了等待,被推到了time 階段的佇列中,如下圖所示,則先執行了timeout 方法。

成為自信的node.js 開發者(二)

所以,setTimeout setImmediate 哪個先執行,這主要取決於,進入event loop 花了多長時間。

但當二者在非同步i/o callback內部呼叫時,總是先執行setImmediate,再執行setTimeout

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

在上述程式碼中,setImmediate 永遠先執行。因為兩個程式碼寫在 IO 回撥中,IO 回撥是在 poll 階段執行,當回撥執行完畢後佇列為空,發現存在 setImmediate 回撥,所以就直接跳轉到 check 階段去執行回撥了。

process.nextTick() 和 setImmediate()

官方推薦使用 setImmediate(),因為更容易推理,也相容更多的環境,例如瀏覽器環境

process.nextTick() 在當前迴圈階段結束之前觸發

setImmediate() 在下一個事件迴圈中的check階段觸發

通過process.nextTick()觸發的回撥也會在進入下一階段前被執行結束,這會允許使用者遞迴呼叫 process.nextTick() 造成I/O被榨乾,使EL不能進入poll階段

因此node作者推薦我們儘量使用setImmediate,因為它只在check階段執行,不至於導致其他非同步回撥無法被執行到

例子

console.log('start')
setTimeout(() => {
  console.log('timer1')
  Promise.resolve().then(function() {
    console.log('promise1')
  })
}, 0)
setTimeout(() => {
  console.log('timer2')
  Promise.resolve().then(function() {
    console.log('promise2')
  })
}, 0)
Promise.resolve().then(function() {
  console.log('promise3')
})
console.log('end')
複製程式碼

注意:主棧執行完了之後,會先清空 process.nextick() 佇列和microtask佇列中的任務,然後按照每一個階段來執行先處理非同步事件的回撥,比如網路I/O,比如檔案讀取I/O。當這些I/O動作都結束的時候,在這個階段會觸發它們的

另外一個例子

const {readFile} = require('fs')

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

setTimeout(() => {
    console.log('2')
}, 100)

setTimeout(() => {
    console.log('3')
}, 200)

readFile('./test.js', () => {
    console.log('4')
})

readFile(__filename, () => {
    console.log('5')
})

setImmediate(() => {
    console.log('立即回撥')
})

process.nextTick(() => {
    console.log('process.nexttick的回撥')
})

Promise.resolve().then(() => {

    process.nextTick(() => {
        console.log('nexttick 第二次回撥')
    })
    console.log('6')
}).then(() => {
    console.log('7')
})
複製程式碼

上面程式碼的結果是:

process.nexttick的回撥
6
7
nexttick 第二次回撥
1
立即回撥
4
5
2
3
複製程式碼

上面程式碼需要注意點:

  1. 下面兩個回撥任務,要等100ms200ms 才能被推入到timers 階段的任務佇列

    成為自信的node.js 開發者(二)

  2. 兩個讀取檔案的回撥,需要等待讀取完成後,才能被推入到 poll 階段的任務佇列。(不是被推入到 io 階段的任務佇列,只有讀取失敗等異常的回撥,才會被推入到 io 階段的任務佇列)

  3. 在微任務裡面,新新增的process.nextTick() 也會在新階段的開始之前被執行。簡單理解為,在每一個階段的任務佇列開始之前,都需要全部清空process.nextTickmicrotask 任務佇列

一個誤區

自己在驗證上面的想法的時候,實驗過很多程式碼,從未失手過,但是當實驗到下面的程式碼時:

Promise.resolve().then(() => {
    console.log(1)
    Promise.resolve().then(() => {
        console.log(2)
    })
}).then(() => {
    console.log(3)
})
複製程式碼

按照上面我們講的,這裡應該是輸出132, 但是反覆驗證,在 node 實際輸出的是 123,連續好幾天都不得其解,後來看到一個問答,才恍然大悟: stackoverflow.com/questions/3…

首先,上面的程式碼,在.then() 的回撥函式中去執行promise.resolve(), 實際上是, 在目前的promise 鏈中新建了一個獨立的 promise鏈 。 你沒有任何辦法保證這兩個哪個先執行完,這實際上是node引擎 的一個bug,就像一口氣發出兩個請求,並不知道哪個請求先返回。

每次我們都能得到相同的結果是因為,我們Promise.resolve()裡面恰好沒有非同步的操作,這並不是event-loop 專門設計成這樣的。

所以,不必花太多的時間,在上面的程式碼中,實際寫程式碼中,也不會出現這種情況。

相關文章