
https://zhuanlan.zhihu.com/p/55511602
一次弄懂Event Loop(徹底解決此類面試問題)
可能存在事實錯誤
轉載: 一次弄懂Event Loop(徹底解決此類面試問題)
作者:光光同學
出處:掘金
文章為轉載,不喜勿噴,都是前端狗,相煎何太急
前言
Event Loop即事件循環,是指瀏覽器或Node的一種解決javaScript單線程運行時不會阻塞的一種機制,也就是我們經常使用異步的原理。
為啥要弄懂Event Loop
- 是要增加自己技術的深度,也就是懂得
JavaScript的運行機制。 - 現在在前端領域各種技術層出不窮,掌握底層原理,可以讓自己以不變,應萬變。
- 應對各大互聯網公司的面試,懂其原理,題目任其發揮。
堆,棧、隊列
堆(Heap)
堆是一種數據結構,是利用完全二叉樹維護的一組數據,堆分為兩種,一種為最大堆,一種為最小堆,將根節點最大的堆叫做最大堆或大根堆,根節點最小的堆叫做最小堆或小根堆。
堆是線性數據結構,相當於一維數組,有唯一后繼。
如最大堆
棧(Stack)
棧在計算機科學中是限定僅在表尾進行插入或刪除操作的線性表。棧是一種數據結構,它按照后進先出的原則存儲數據,先進入的數據被壓入棧底,最后的數據在棧頂,需要讀數據的時候從棧頂開始彈出數據。
棧是只能在某一端插入和刪除的特殊線性表。
隊列(Queue)
特殊之處在於它只允許在表的前端(front)進行刪除操作,而在表的后端(rear)進行插入操作,和棧一樣,隊列是一種操作受限制的線性表。
進行插入操作的端稱為隊尾,進行刪除操作的端稱為隊頭。 隊列中沒有元素時,稱為空隊列。
隊列的數據元素又稱為隊列元素。在隊列中插入一個隊列元素稱為入隊,從隊列中刪除一個隊列元素稱為出隊。因為隊列只允許在一端插入,在另一端刪除,所以只有最早進入隊列的元素才能最先從隊列中刪除,故隊列又稱為先進先出(FIFO—first in first out)
Event Loop
在JavaScript中,任務被分為兩種,一種宏任務(MacroTask)也叫Task,一種叫微任務(MicroTask)。
MacroTask(宏任務)
script全部代碼、setTimeout、setInterval、setImmediate(瀏覽器暫時不支持,只有IE10支持,具體可見MDN)、I/O、UI Rendering。
MicroTask(微任務)
Process.nextTick(Node獨有)、Promise、Object.observe(廢棄)、MutationObserver(具體使用方式查看這里)
瀏覽器中的Event Loop
Javascript 有一個 main thread 主線程和 call-stack 調用棧(執行棧),所有的任務都會被放到調用棧等待主線程執行。
JS調用棧
JS調用棧采用的是后進先出的規則,當函數執行的時候,會被添加到棧的頂部,當執行棧執行完成后,就會從棧頂移出,直到棧內被清空。
同步任務和異步任務
Javascript單線程任務被分為同步任務和異步任務,同步任務會在調用棧中按照順序等待主線程依次執行,異步任務會在異步任務有了結果后,將注冊的回調函數放入任務隊列中等待主線程空閑的時候(調用棧被清空),被讀取到棧內等待主線程的執行。
任務隊列Task Queue,即隊列,是一種先進先出的一種數據結構。
事件循環的進程模型
- 選擇當前要執行的任務隊列,選擇任務隊列中最先進入的任務,如果任務隊列為空即
null,則執行跳轉到微任務(MicroTask)的執行步驟。 - 將事件循環中的任務設置為已選擇任務。
- 執行任務。
- 將事件循環中當前運行任務設置為null。
- 將已經運行完成的任務從任務隊列中刪除。
- microtasks步驟:進入microtask檢查點。
- 更新界面渲染。
- 返回第一步。
執行進入microtask檢查點時,用戶代理會執行以下步驟:
- 設置microtask檢查點標志為true。
- 當事件循環
microtask執行不為空時:選擇一個最先進入的microtask隊列的microtask,將事件循環的microtask設置為已選擇的microtask,運行microtask,將已經執行完成的microtask為null,移出microtask中的microtask。 - 清理IndexDB事務
- 設置進入microtask檢查點的標志為false。
上述可能不太好理解,下圖是我做的一張圖片。
執行棧在執行完同步任務后,查看執行棧是否為空,如果執行棧為空,就會去執行Task(宏任務),每次宏任務執行完畢后,檢查微任務(microTask)隊列是否為空,如果不為空的話,會按照先入先出的規則全部執行完微任務(microTask)后,設置微任務(microTask)隊列為null,然后再執行宏任務,如此循環。
舉個例子
console.log('script start'); setTimeout(function() { console.log('setTimeout'); }, 0); Promise.resolve().then(function() { console.log('promise1'); }).then(function() { console.log('promise2'); }); console.log('script end');
首先我們划分幾個分類:
第一次執行:
Tasks:run script、 setTimeout callback Microtasks:Promise then JS stack: script Log: script start、script end。
執行同步代碼,將宏任務(Tasks)和微任務(Microtasks)划分到各自隊列中。
第二次執行:
Tasks:run script、 setTimeout callback Microtasks:Promise2 then JS stack: Promise2 callback Log: script start、script end、promise1、promise2
執行宏任務后,檢測到微任務(Microtasks)隊列中不為空,執行Promise1,執行完成Promise1后,調用Promise2.then,放入微任務(Microtasks)隊列中,再執行Promise2.then。
第三次執行:
Tasks:setTimeout callback Microtasks: JS stack: setTimeout callback Log: script start、script end、promise1、promise2、setTimeout
當微任務(Microtasks)隊列中為空時,執行宏任務(Tasks),執行setTimeout callback,打印日志。
第四次執行:
Tasks:setTimeout callback Microtasks: JS stack: Log: script start、script end、promise1、promise2、setTimeout
清空Tasks隊列和JS stack。
以上執行幀動畫可以查看Tasks, microtasks, queues and schedules
或許這張圖也更好理解些。
再舉個例子
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') }) .then(function() { console.log('promise2') }) console.log('script end')
這里需要先理解async/await。async/await 在底層轉換成了 promise 和 then 回調函數。
也就是說,這是 promise 的語法糖。
每次我們使用 await, 解釋器都創建一個 promise 對象,然后把剩下的 async 函數中的操作放到 then 回調函數中。async/await 的實現,離不開 Promise。從字面意思來理解,async 是“異步”的簡寫,而 await 是 async wait 的簡寫可以認為是等待異步方法執行完成。
關於73以下版本和73版本的區別
- 在73版本以下,先執行
promise1和promise2,再執行async1。 - 在73版本,先執行
async1再執行promise1和promise2。
主要原因是因為在谷歌(金絲雀)73版本中更改了規范,如下圖所示:
- 區別在於
RESOLVE(thenable)和之間的區別Promise.resolve(thenable)。
在73以下版本中
- 首先,傳遞給
await的值被包裹在一個Promise中。然后,處理程序附加到這個包裝的Promise,以便在Promise變為fulfilled后恢復該函數,並且暫停執行異步函數,一旦promise變為fulfilled,恢復異步函數的執行。 - 每個
await引擎必須創建兩個額外的 Promise(即使右側已經是一個Promise)並且它需要至少三個microtask隊列ticks(tick為系統的相對時間單位,也被稱為系統的時基,來源於定時器的周期性中斷(輸出脈沖),一次中斷表示一個tick,也被稱做一個“時鍾滴答”、時標。)。
引用賀老師知乎上的一個例子
async function f() { await p console.log('ok') }
簡化理解為:
function f() { return RESOLVE(p).then(() => { console.log('ok') }) }
- 如果
RESOLVE(p)對於p為promise直接返回p的話,那么p的then方法就會被馬上調用,其回調就立即進入job隊列。 - 而如果 RESOLVE(p) 嚴格按照標准,應該是產生一個新的 promise,盡管該 promise確定會 resolve 為 p,但這個過程本身是異步的,也就是現在進入 job 隊列的是新 promise 的 resolve過程,所以該 promise 的 then 不會被立即調用,而要等到當前 job 隊列執行到前述 resolve 過程才會被調用,然后其回調(也就是繼續 await 之后的語句)才加入 job 隊列,所以時序上就晚了。
谷歌(金絲雀)73版本中
- 使用對
PromiseResolve的調用來更改await的語義,以減少在公共awaitPromise情況下的轉換次數。 - 如果傳遞給
await的值已經是一個Promise,那么這種優化避免了再次創建Promise包裝器,在這種情況下,我們從最少三個microtick到只有一個microtick。
詳細過程:
73以下版本
- 首先,打印
script start,調用async1()時,返回一個Promise,所以打印出來async2 end。 - 每個
await,會新產生一個promise,但這個過程本身是異步的,所以該await后面不會立即調用。 - 繼續執行同步代碼,打印
Promise和script end,將then函數放入微任務隊列中等待執行。 - 同步執行完成之后,檢查微任務隊列是否為
null,然后按照先入先出規則,依次執行。 - 然后先執行打印
promise1,此時then的回調函數返回undefinde,此時又有then的鏈式調用,又放入微任務隊列中,再次打印promise2。 - 再回到
await的位置執行返回的Promise的resolve函數,這又會把resolve丟到微任務隊列中,打印async1 end。 - 當微任務隊列為空時,執行宏任務,打印
setTimeout。
谷歌(金絲雀73版本)
- 如果傳遞給
await的值已經是一個Promise,那么這種優化避免了再次創建Promise包裝器,在這種情況下,我們從最少三個microtick到只有一個microtick。 - 引擎不再需要為
await創造throwaway Promise- 在絕大部分時間。 - 現在
promise指向了同一個Promise,所以這個步驟什么也不需要做。然后引擎繼續像以前一樣,創建throwaway Promise,安排PromiseReactionJob在microtask隊列的下一個tick上恢復異步函數,暫停執行該函數,然后返回給調用者。
具體詳情查看(這里)。
NodeJS的Event Loop
Node中的Event Loop是基於libuv實現的,而libuv是 Node 的新跨平台抽象層,libuv使用異步,事件驅動的編程方式,核心是提供i/o的事件循環和異步回調。libuv的API包含有時間,非阻塞的網絡,異步文件操作,子進程等等。 Event Loop就是在libuv中實現的。
Node的Event loop一共分為6個階段,每個細節具體如下:
timers: 執行setTimeout和setInterval中到期的callback。pending callback: 上一輪循環中少數的callback會放在這一階段執行。idle, prepare: 僅在內部使用。poll: 最重要的階段,執行pending callback,在適當的情況下回阻塞在這個階段。check: 執行setImmediate(setImmediate()是將事件插入到事件隊列尾部,主線程和事件隊列的函數執行完成之后立即執行setImmediate指定的回調函數)的callback。close callbacks: 執行close事件的callback,例如socket.on('close'[,fn])或者http.server.on('close, fn)。
具體細節如下:
timers
執行setTimeout和setInterval中到期的callback,執行這兩者回調需要設置一個毫秒數,理論上來說,應該是時間一到就立即執行callback回調,但是由於system的調度可能會延時,達不到預期時間。
以下是官網文檔解釋的例子:
const fs = require('fs'); function someAsyncOperation(callback) { // Assume this takes 95ms to complete fs.readFile('/path/to/file', callback); } const timeoutScheduled = Date.now(); setTimeout(() => { const delay = Date.now() - timeoutScheduled; console.log(`${delay}ms have passed since I was scheduled`); }, 100); // do someAsyncOperation which takes 95 ms to complete someAsyncOperation(() => { const startCallback = Date.now(); // do something that will take 10ms... while (Date.now() - startCallback < 10) { // do nothing } });
當進入事件循環時,它有一個空隊列(fs.readFile()尚未完成),因此定時器將等待剩余毫秒數,當到達95ms時,fs.readFile()完成讀取文件並且其完成需要10毫秒的回調被添加到輪詢隊列並執行。
當回調結束時,隊列中不再有回調,因此事件循環將看到已達到最快定時器的閾值,然后回到timers階段以執行定時器的回調。
在此示例中,您將看到正在調度的計時器與正在執行的回調之間的總延遲將為105毫秒。
以下是我測試時間:
pending callbacks
此階段執行某些系統操作(例如TCP錯誤類型)的回調。 例如,如果TCP socket ECONNREFUSED在嘗試connect時receives,則某些* nix系統希望等待報告錯誤。 這將在pending callbacks階段執行。
poll
該poll階段有兩個主要功能:
- 執行
I/O回調。 - 處理輪詢隊列中的事件。
當事件循環進入poll階段並且在timers中沒有可以執行定時器時,將發生以下兩種情況之一
- 如果
poll隊列不為空,則事件循環將遍歷其同步執行它們的callback隊列,直到隊列為空,或者達到system-dependent(系統相關限制)。
如果poll隊列為空,則會發生以下兩種情況之一
- 如果有
setImmediate()回調需要執行,則會立即停止執行poll階段並進入執行check階段以執行回調。 - 如果沒有
setImmediate()回到需要執行,poll階段將等待callback被添加到隊列中,然后立即執行。
當然設定了 timer 的話且 poll 隊列為空,則會判斷是否有 timer 超時,如果有的話會回到 timer 階段執行回調。
check
此階段允許人員在poll階段完成后立即執行回調。
如果poll階段閑置並且script已排隊setImmediate(),則事件循環到達check階段執行而不是繼續等待。
setImmediate()實際上是一個特殊的計時器,它在事件循環的一個單獨階段運行。它使用libuv API來調度在poll階段完成后執行的回調。
通常,當代碼被執行時,事件循環最終將達到poll階段,它將等待傳入連接,請求等。
但是,如果已經調度了回調setImmediate(),並且輪詢階段變為空閑,則它將結束並且到達check階段,而不是等待poll事件。
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')
如果node版本為v11.x, 其結果與瀏覽器一致。
start end promise3 timer1 timer2 promise1 promise2
具體詳情可以查看《又被node的eventloop坑了,這次是node的鍋》。
如果v10版本上述結果存在兩種情況:
- 如果time2定時器已經在執行隊列中了,那么執行結果與上面結果相同。
- 如果time2定時器沒有在執行對列中,執行結果為
start end promise3 timer1 promise1 timer2
具體情況可以參考poll階段的兩種情況。
從下圖可能更好理解:
setImmediate() 的setTimeout()的區別
setImmediate和setTimeout()是相似的,但根據它們被調用的時間以不同的方式表現。
setImmediate()設計用於在當前poll階段完成后check階段執行腳本 。setTimeout()安排在經過最小(ms)后運行的腳本,在timers階段執行。
舉個例子
setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); });
執行定時器的順序將根據調用它們的上下文而有所不同。 如果從主模塊中調用兩者,那么時間將受到進程性能的限制。
其結果也不一致
如果在I / O周期內移動兩個調用,則始終首先執行立即回調:
const fs = require('fs'); fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); }); });
其結果可以確定一定是immediate => timeout。
主要原因是在I/O階段讀取文件后,事件循環會先進入poll階段,發現有setImmediate需要執行,會立即進入check階段執行setImmediate的回調。
然后再進入timers階段,執行setTimeout,打印timeout。
Process.nextTick()
process.nextTick()雖然它是異步API的一部分,但未在圖中顯示。這是因為process.nextTick()從技術上講,它不是事件循環的一部分。
process.nextTick()方法將callback添加到next tick隊列。 一旦當前事件輪詢隊列的任務全部完成,在next tick隊列中的所有callbacks會被依次調用。
換種理解方式:
- 當每個階段完成后,如果存在
nextTick隊列,就會清空隊列中的所有回調函數,並且優先於其他microtask執行。
例子
let bar; setTimeout(() => { console.log('setTimeout'); }, 0) setImmediate(() => { console.log('setImmediate'); }) function someAsyncApiCall(callback) { process.nextTick(callback); } someAsyncApiCall(() => { console.log('bar', bar); // 1 }); bar = 1;
在NodeV10中上述代碼執行可能有兩種答案,一種為:
bar 1 setTimeout setImmediate
另一種為:
bar 1 setImmediate setTimeout
無論哪種,始終都是先執行process.nextTick(callback),打印bar 1。
