一次弄懂Event Loop(徹底解決此類面試問題)

光光同學發表於2019-01-20

前言

Event Loop即事件迴圈,是指瀏覽器或Node的一種解決javaScript單執行緒執行時不會阻塞的一種機制,也就是我們經常使用非同步的原理。

為啥要弄懂Event Loop

  • 是要增加自己技術的深度,也就是懂得JavaScript的執行機制。

  • 現在在前端領域各種技術層出不窮,掌握底層原理,可以讓自己以不變,應萬變。

  • 應對各大網際網路公司的面試,懂其原理,題目任其發揮。

堆,棧、佇列

一次弄懂Event Loop(徹底解決此類面試問題)

堆(Heap)

是一種資料結構,是利用完全二叉樹維護的一組資料,分為兩種,一種為最大,一種為最小堆,將根節點最大叫做最大堆大根堆,根節點最小叫做最小堆小根堆
線性資料結構,相當於一維陣列,有唯一後繼。

如最大堆

一次弄懂Event Loop(徹底解決此類面試問題)

棧(Stack)

在電腦科學中是限定僅在表尾進行插入刪除操作的線性表。 是一種資料結構,它按照後進先出的原則儲存資料,先進入的資料被壓入棧底最後的資料棧頂,需要讀資料的時候從棧頂開始彈出資料
是隻能在某一端插入刪除特殊線性表

一次弄懂Event Loop(徹底解決此類面試問題)

佇列(Queue)

特殊之處在於它只允許在表的前端(front)進行刪除操作,而在表的後端(rear)進行插入操作,和一樣,佇列是一種操作受限制的線性表。
進行插入操作的端稱為隊尾,進行刪除操作的端稱為隊頭。 佇列中沒有元素時,稱為空佇列

佇列的資料元素又稱為佇列元素。在佇列中插入一個佇列元素稱為入隊,從佇列刪除一個佇列元素稱為出隊。因為佇列只允許在一端插入,在另一端刪除,所以只有最早進入佇列的元素才能最先從佇列中刪除,故佇列又稱為先進先出FIFO—first in first out

一次弄懂Event Loop(徹底解決此類面試問題)

Event Loop

JavaScript中,任務被分為兩種,一種巨集任務(MacroTask)也叫Task,一種叫微任務(MicroTask)。

MacroTask(巨集任務)

  • script全部程式碼、setTimeoutsetIntervalsetImmediate(瀏覽器暫時不支援,只有IE10支援,具體可見MDN)、I/OUI Rendering

MicroTask(微任務)

  • Process.nextTick(Node獨有)PromiseObject.observe(廢棄)MutationObserver(具體使用方式檢視這裡

瀏覽器中的Event Loop

Javascript 有一個 main thread 主執行緒和 call-stack 呼叫棧(執行棧),所有的任務都會被放到呼叫棧等待主執行緒執行。

JS呼叫棧

JS呼叫棧採用的是後進先出的規則,當函式執行的時候,會被新增到棧的頂部,當執行棧執行完成後,就會從棧頂移出,直到棧內被清空。

同步任務和非同步任務

Javascript單執行緒任務被分為同步任務非同步任務,同步任務會在呼叫棧中按照順序等待主執行緒依次執行,非同步任務會在非同步任務有了結果後,將註冊的回撥函式放入任務佇列中等待主執行緒空閒的時候(呼叫棧被清空),被讀取到棧內等待主執行緒的執行。

一次弄懂Event Loop(徹底解決此類面試問題)
任務佇列Task Queue,即佇列,是一種先進先出的一種資料結構。

一次弄懂Event Loop(徹底解決此類面試問題)

事件迴圈的程式模型

  • 選擇當前要執行的任務佇列,選擇任務佇列中最先進入的任務,如果任務佇列為空即null,則執行跳轉到微任務(MicroTask)的執行步驟。
  • 將事件迴圈中的任務設定為已選擇任務。
  • 執行任務。
  • 將事件迴圈中當前執行任務設定為null。
  • 將已經執行完成的任務從任務佇列中刪除。
  • microtasks步驟:進入microtask檢查點。
  • 更新介面渲染。
  • 返回第一步。

執行進入microtask檢查點時,使用者代理會執行以下步驟:

  • 設定microtask檢查點標誌為true。
  • 當事件迴圈microtask執行不為空時:選擇一個最先進入的microtask佇列的microtask,將事件迴圈的microtask設定為已選擇的microtask,執行microtask,將已經執行完成的microtasknull,移出microtask中的microtask
  • 清理IndexDB事務
  • 設定進入microtask檢查點的標誌為false。

上述可能不太好理解,下圖是我做的一張圖片。

一次弄懂Event Loop(徹底解決此類面試問題)

執行棧在執行完同步任務後,檢視執行棧是否為空,如果執行棧為空,就會去檢查微任務(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
或許這張圖也更好理解些。

一次弄懂Event Loop(徹底解決此類面試問題)

再舉個例子

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 在底層轉換成了 promisethen 回撥函式。
也就是說,這是 promise 的語法糖。
每次我們使用 await, 直譯器都建立一個 promise 物件,然後把剩下的 async 函式中的操作放到 then 回撥函式中。
async/await 的實現,離不開 Promise。從字面意思來理解,async 是“非同步”的簡寫,而 awaitasync wait 的簡寫可以認為是等待非同步方法執行完成。

關於73以下版本和73版本的區別

  • 在老版本版本以下,先執行promise1promise2,再執行async1
  • 在73版本,先執行async1再執行promise1promise2

主要原因是因為在谷歌(金絲雀)73版本中更改了規範,如下圖所示:

一次弄懂Event Loop(徹底解決此類面試問題)

  • 區別在於RESOLVE(thenable)和之間的區別Promise.resolve(thenable)

在老版本中

  • 首先,傳遞給 await 的值被包裹在一個 Promise 中。然後,處理程式附加到這個包裝的 Promise,以便在 Promise 變為 fulfilled 後恢復該函式,並且暫停執行非同步函式,一旦 promise 變為 fulfilled,恢復非同步函式的執行。
  • 每個 await 引擎必須建立兩個額外的 Promise(即使右側已經是一個 Promise)並且它需要至少三個 microtask 佇列 tickstick為系統的相對時間單位,也被稱為系統的時基,來源於定時器的週期性中斷(輸出脈衝),一次中斷表示一個tick,也被稱做一個“時鐘滴答”、時標。)。

引用賀老師知乎上的一個例子

async function f() {
  await p
  console.log('ok')
}
複製程式碼

簡化理解為:


function f() {
  return RESOLVE(p).then(() => {
    console.log('ok')
  })
}
複製程式碼
  • 如果 RESOLVE(p) 對於 ppromise 直接返回 p 的話,那麼 pthen 方法就會被馬上呼叫,其回撥就立即進入 job 佇列。
  • 而如果 RESOLVE(p) 嚴格按照標準,應該是產生一個新的 promise,儘管該 promise確定會 resolvep,但這個過程本身是非同步的,也就是現在進入 job 佇列的是新 promiseresolve過程,所以該 promisethen 不會被立即呼叫,而要等到當前 job 佇列執行到前述 resolve 過程才會被呼叫,然後其回撥(也就是繼續 await 之後的語句)才加入 job 佇列,所以時序上就晚了。

谷歌(金絲雀)73版本中

  • 使用對PromiseResolve的呼叫來更改await的語義,以減少在公共awaitPromise情況下的轉換次數。
  • 如果傳遞給 await 的值已經是一個 Promise,那麼這種優化避免了再次建立 Promise 包裝器,在這種情況下,我們從最少三個 microtick 到只有一個 microtick

詳細過程:

73以下版本

  • 首先,列印script start,呼叫async1()時,返回一個Promise,所以列印出來async2 end
  • 每個 await,會新產生一個promise,但這個過程本身是非同步的,所以該await後面不會立即呼叫。
  • 繼續執行同步程式碼,列印Promisescript end,將then函式放入微任務佇列中等待執行。
  • 同步執行完成之後,檢查微任務佇列是否為null,然後按照先入先出規則,依次執行。
  • 然後先執行列印promise1,此時then的回撥函式返回undefinde,此時又有then的鏈式呼叫,又放入微任務佇列中,再次列印promise2
  • 再回到await的位置執行返回的 Promiseresolve 函式,這又會把 resolve 丟到微任務佇列中,列印async1 end
  • 微任務佇列為空時,執行巨集任務,列印setTimeout

谷歌(金絲雀73版本)

  • 如果傳遞給 await 的值已經是一個 Promise,那麼這種優化避免了再次建立 Promise 包裝器,在這種情況下,我們從最少三個 microtick 到只有一個 microtick
  • 引擎不再需要為 await 創造 throwaway Promise - 在絕大部分時間。
  • 現在 promise 指向了同一個 Promise,所以這個步驟什麼也不需要做。然後引擎繼續像以前一樣,建立 throwaway Promise,安排 PromiseReactionJobmicrotask 佇列的下一個 tick 上恢復非同步函式,暫停執行該函式,然後返回給呼叫者。

具體詳情檢視(這裡)。

NodeJS的Event Loop

一次弄懂Event Loop(徹底解決此類面試問題)

Node中的Event Loop是基於libuv實現的,而libuvNode 的新跨平臺抽象層,libuv使用非同步,事件驅動的程式設計方式,核心是提供i/o的事件迴圈和非同步回撥。libuv的API包含有時間,非阻塞的網路,非同步檔案操作,子程式等等。 Event Loop就是在libuv中實現的。

一次弄懂Event Loop(徹底解決此類面試問題)

NodeEvent loop一共分為6個階段,每個細節具體如下:

  • timers: 執行setTimeoutsetInterval中到期的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

執行setTimeoutsetInterval中到期的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毫秒。

以下是我測試時間:

一次弄懂Event Loop(徹底解決此類面試問題)

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階段的兩種情況。

從下圖可能更好理解:

一次弄懂Event Loop(徹底解決此類面試問題)

setImmediate() 的setTimeout()的區別

setImmediatesetTimeout()是相似的,但根據它們被呼叫的時間以不同的方式表現。

  • 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官網

相關文章