成為自信的node.js 開發者(二)
這一章,我們來學習一下event_loop, 本文內容旨在釐清瀏覽器(browsing context)和Node環境中不同的 Event Loop。
首先清楚一點:瀏覽器環境和 node環境的 event-loop
完全不一樣。
瀏覽器環境
為了協調事件、使用者互動、指令碼、UI渲染、網路請求等行為,使用者引擎必須使用 Event Loop
。 event loop
包含兩類:基於browsing contexts,基於worker。
本文討論的瀏覽器中的EL基於browsing contexts
上面圖中,關鍵性的兩點:
同步任務直接進入主執行棧(call stack)中執行
等待主執行棧中任務執行完畢,由EL將非同步任務推入主執行棧中執行
task——巨集任務
task在網上也被成為 macrotask
(巨集任務)
巨集任務分類:
script程式碼
setTimeout/setInterval
setImmediate (未實現)
I/O
UI互動
巨集任務特徵
一個 event loop
中,有一個或多個 task佇列。
不同的task會放入不同的task佇列中:比如,瀏覽器會為滑鼠鍵盤事件分配一個task佇列,為其他的事件分配另外的佇列。
先進佇列的先被執行
microtask——微任務
微任務
微任務的分類
通常下面幾種任務被認為是microtask
promise( promise
的 then
和 catch
才是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' 複製程式碼
-
await 只是
fn().then()
這些寫法的語法糖,相當於await
那一行程式碼下面的程式碼都被當成一個微任務,推入到了microtask queue
中 -
順序:執行完同步任務,執行微任務佇列中的全部的微任務,執行一個巨集任務,執行全部的微任務
node 環境中
Node中的 event-loop
由 libuv庫 實現,js是單執行緒的,會把回撥和任務交給libuv
event loop
首先會在內部維持多個事件佇列,比如 時間佇列、網路佇列等等,而libuv會執行一個相當於 while true的無限迴圈,不斷的檢查各個事件佇列上面是否有需要處理的pending狀態事件,如果有則按順序去觸發佇列裡面儲存的事件,同時由於libuv的事件迴圈每次只會執行一個回撥,從而避免了 競爭的發生
個人理解,它與瀏覽器中的輪詢機制(一個task,所有microtasks;一個task,所有microtasks…)最大的不同是,node輪詢有phase(階段)的概念,不同的任務在不同階段執行,進入下一階段之前執行所有的process.nextTick() 和 所有的microtasks。
階段
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為空 如果有設定`setImmediate() 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事件會在這個階段被觸發 複製程式碼
同步的任務執行完,先執行完全部的 process.nextTick()
和 全部的微任務佇列,然後執行每一個階段,每個階段執行完畢後,
注意點
setTimeout 和 setImmediate
-
呼叫階段不一樣
-
不同的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 按照 time
、 i/o
、 poll
、 check
、 close
順序執行,先執行 immediate
任務。
也有可能,進入event loop 時, setTimeout(fn, 1)
已經結束了等待,被推到了 time
階段的佇列中,如下圖所示,則先執行了 timeout
方法。
所以, 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動作都結束的時候,在這個階段會觸發它們的
下一期,我們再見~