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 LoopJavascript 有一個 main thread 主線程和 call-stack 調用棧(執行棧),所有的任務都會被放到調用棧等待主線程執行。
JS調用棧JS調用棧採用的是後進先出的規則,當函數執行的時候,會被添加到棧的頂部,當執行棧執行完成後,就會從棧頂移出,直到棧內被清空。
同步任務和異步任務Javascript單線程任務被分為同步任務和異步任務,同步任務會在調用棧中按照順序等待主線程依次執行,異步任務會在異步任務有了結果後,將註冊的回調函數放入任務隊列中等待主線程空閒的時候(調用棧被清空),被讀取到棧內等待主線程的執行。
任務隊列Task Queue,即隊列,是一種先進先出的一種數據結構。
事件循環的進程模型選擇當前要執行的任務隊列,選擇任務隊列中最先進入的任務,如果任務隊列為空即null,則執行跳轉到微任務(MicroTask)的執行步驟。microtasks步驟:進入microtask檢查點。執行進入microtask檢查點時,用戶代理會執行以下步驟:當事件循環microtask執行不為空時:選擇一個最先進入的microtask隊列的microtask,將事件循環的microtask設置為已選擇的microtask,運行microtask,將已經執行完成的microtask為null,移出microtask中的microtask。設置進入microtask檢查點的標誌為false。上述可能不太好理解,下圖是我做的一張圖片。
img執行棧在執行完同步任務後,查看執行棧是否為空,如果執行棧為空,就會去檢查微任務(microTask)隊列是否為空,如果為空的話,就執行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。
再舉個例子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版本的區別在老版本版本以下,先執行promise1和promise2,再執行async1。在73版本,先執行async1再執行promise1和promise2。主要原因是因為在谷歌(金絲雀)73版本中更改了規範,如下圖所示:
區別在於RESOLVE(thenable)和之間的區別Promise.resolve(thenable)。在老版本中首先,傳遞給 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 LoopNode中的Event Loop是基於libuv實現的,而libuv是 Node 的新跨平臺抽象層,libuv使用異步,事件驅動的編程方式,核心是提供i/o的事件循環和異步回調。libuv的API包含有時間,非阻塞的網絡,異步文件操作,子進程等等。Event Loop就是在libuv中實現的。
Node的Event loop一共分為6個階段,每個細節具體如下:timers: 執行setTimeout和setInterval中到期的callback。pending callback: 上一輪循環中少數的callback會放在這一階段執行。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階段有兩個主要功能:
當事件循環進入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
promise1
timer2
promise2
複製代碼如果v10版本上述結果存在兩種情況:
start
end
promise3
timer1
timer2
promise1
promise2
複製代碼start
end
promise3
timer1
promise1
timer2
promise2
複製代碼具體情況可以參考poll階段的兩種情況。
從下圖可能更好理解:
imgsetImmediate() 的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。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
複製代碼
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。
來源 :https://juejin.cn/post/6844903764202094606