前言
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。
上述可能不太好理解,下圖是我做的一張圖片。
執行棧在執行完同步任務後,檢視執行棧是否為空,如果執行棧為空,就會去檢查微任務(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
。
以上執行幀動畫可以檢視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版本的區別
- 在老版本版本以下,先執行
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 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
promise1
timer2
promise2
複製程式碼
具體詳情可以檢視《又被node的eventloop坑了,這次是node的鍋》。
如果v10版本上述結果存在兩種情況:
- 如果time2定時器已經在執行佇列中了
start
end
promise3
timer1
timer2
promise1
promise2
複製程式碼
- 如果time2定時器沒有在執行對列中,執行結果為
start
end
promise3
timer1
promise1
timer2
promise2
複製程式碼
具體情況可以參考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
。
┌───────────────────────────┐
┌─>│ 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
。
最後
感謝@Dante_Hu提出這個問題await
的問題,文章已經修正。
修改了node端執行結果。V10和V11的區別。
關於await問題參考了以下文章:.
《promise, async, await, execution order》
《Normative: Reduce the number of ticks in async/await》
《async/await 在chrome 環境和 node 環境的 執行結果不一致,求解?》
《更快的非同步函式和 Promise》
其他內容參考了:
《JS瀏覽器事件迴圈機制》
《什麼是瀏覽器的事件迴圈(Event Loop)?》
《一篇文章教會你Event loop——瀏覽器和Node》
《不要混淆nodejs和瀏覽器中的event loop》
《瀏覽器與Node的事件迴圈(Event Loop)有何區別?》
《Tasks, microtasks, queues and schedules》
《前端面試之道》
《Node.js介紹5-libuv的基本概念》
《The Node.js Event Loop, Timers, and process.nextTick()》
《node官網》