一次弄懂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
的簡寫可以認為是等待非同步方法執行完成。
- 首先,列印
script start
,呼叫async1()
時,返回一個Promise
,所以打印出來async2 end
,當遇到await的時候會就讓出執行緒開始執行async1
外的程式碼,所以我們完全可以把await
看成是讓出執行緒的標誌。 - 繼續執行同步程式碼,列印
Promise
和script end
,將then
函式放入 微任務 佇列中等待執行。 - 同步執行完成之後,檢查 微任務 佇列是否為
null
,然後按照先入先出規則,依次執行。 - 然後先執行列印
promise1
,此時then
的回撥函式返回undefinde
,此時又有then
的鏈式呼叫,又放入 微任務 佇列中,再次列印promise2
。 - 再回到
await
的位置執行返回的Promise
的resolve
函式,這又會把resolve
丟到微任務佇列中,列印async1 end
。 - 當 微任務 佇列為空時,執行巨集任務,列印
setTimeout
。
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
。
┌───────────────────────────┐ ┌─>│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
。