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

前言

Event Loop即事件循環,是指瀏覽器或Node的一種解決javaScript單線程運行時不會阻塞的一種機制,也就是咱們常用異步的原理。javascript

爲啥要弄懂Event Loop

  • 是要增長本身技術的深度,也就是懂得JavaScript的運行機制。html

  • 如今在前端領域各類技術層出不窮,掌握底層原理,可讓本身以不變,應萬變。前端

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

堆,棧、隊列

堆(Heap)

是一種數據結構,是利用徹底二叉樹維護的一組數據,分爲兩種,一種爲最大,一種爲最小堆,將根節點最大叫作最大堆大根堆,根節點最小叫作最小堆小根堆
線性數據結構,至關於一維數組,有惟一後繼。node

如最大堆git

棧(Stack)

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

隊列(Queue)

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

隊列的數據元素又稱爲隊列元素。在隊列中插入一個隊列元素稱爲入隊,從隊列刪除一個隊列元素稱爲出隊。由於隊列只容許在一端插入,在另外一端刪除,因此只有最先進入隊列的元素才能最早從隊列中刪除,故隊列又稱爲先進先出FIFO—first in first outchrome

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單線程任務被分爲同步任務異步任務,同步任務會在調用棧中按照順序等待主線程依次執行,異步任務會在異步任務有告終果後,將註冊的回調函數放入任務隊列中等待主線程空閒的時候(調用棧被清空),被讀取到棧內等待主線程的執行。

任務隊列 Task Queue,即隊列,是一種先進先出的一種數據結構。

事件循環的進程模型

  • 選擇當前要執行的任務隊列,選擇任務隊列中最早進入的任務,若是任務隊列爲空即null,則執行跳轉到微任務(MicroTask)的執行步驟。
  • 將事件循環中的任務設置爲已選擇任務。
  • 執行任務。
  • 將事件循環中當前運行任務設置爲null。
  • 將已經運行完成的任務從任務隊列中刪除。
  • microtasks步驟:進入microtask檢查點。
  • 更新界面渲染。
  • 返回第一步。

執行進入microtask檢查點時,用戶代理會執行如下步驟:

  • 設置microtask檢查點標誌爲true。
  • 當事件循環microtask執行不爲空時:選擇一個最早進入的microtask隊列的microtask,將事件循環的microtask設置爲已選擇的microtask,運行microtask,將已經執行完成的microtasknull,移出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、promise一、promise2
複製代碼

執行宏任務後,檢測到微任務(Microtasks)隊列中不爲空,執行Promise1,執行完成Promise1後,調用Promise2.then,放入微任務(Microtasks)隊列中,再執行Promise2.then

第三次執行:

Tasks:setTimeout callback

Microtasks:	

JS stack: setTimeout callback
Log: script start、script end、promise一、promise二、setTimeout
複製代碼

當微任務(Microtasks)隊列中爲空時,執行宏任務(Tasks),執行setTimeout callback,打印日誌。

第四次執行:

Tasks:setTimeout callback

Microtasks:	

JS stack: 
Log: script start、script end、promise一、promise二、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 在底層轉換成了 promisethen 回調函數。
也就是說,這是 promise 的語法糖。
每次咱們使用 await, 解釋器都建立一個 promise 對象,而後把剩下的 async 函數中的操做放到 then 回調函數中。
async/await 的實現,離不開 Promise。從字面意思來理解,async 是「異步」的簡寫,而 awaitasync wait 的簡寫能夠認爲是等待異步方法執行完成。

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

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

主要緣由是由於在谷歌(金絲雀)73版本中更改了規範,以下圖所示:

  • 區別在於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

Node中的Event Loop是基於libuv實現的,而libuvNode 的新跨平臺抽象層,libuv使用異步,事件驅動的編程方式,核心是提供i/o的事件循環和異步回調。libuv的API包含有時間,非阻塞的網絡,異步文件操做,子進程等等。 Event Loop就是在libuv中實現的。

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毫秒。

如下是我測試時間:

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()的區別

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

相關文章
相關標籤/搜索