天天看點

面試題:說說事件循環機制(滿分答案來了)

答題大綱

  1. 先說基本知識點,宏任務、微任務有哪些
  2. 說事件循環機制過程,邊說邊畫圖出來
  3. 說async/await執行順序注意,可以把 chrome 的優化,做法其實是違反了規範的,V8 團隊的PR這些自信點說出來,顯得你很好學,了解得很詳細,很透徹。
  4. 把node的事件循環也說一下,重複1、2、3點,node中的第3點要說的是node11前後的事件循環變動點。

下面就跟着這個大綱走,每個點來說一下吧~

浏覽器中的事件循環

JavaScript代碼的執行過程中,除了依靠函數調用棧來搞定函數的執行順序外,還依靠任務隊列(task queue)來搞定另外一些代碼的執行。整個執行過程,我們稱為事件循環過程。一個線程中,事件循環是唯一的,但是任務隊列可以擁有多個。任務隊列又分為macro-task(宏任務)與micro-task(微任務),在最新标準中,它們被分别稱為task與jobs。

macro-task大概包括:

  • script(整體代碼)
  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • UI render

micro-task大概包括:

  • process.nextTick
  • Promise
  • Async/Await(實際就是promise)
  • MutationObserver(html5新特性)

整體執行,我畫了一個流程圖:

面試題:說說事件循環機制(滿分答案來了)

GitHub

總的結論就是,執行宏任務,然後執行該宏任務産生的微任務,若微任務在執行過程中産生了新的微任務,則繼續執行微任務,微任務執行完畢後,再回到宏任務中進行下一輪循環。舉個例子:

面試題:說說事件循環機制(滿分答案來了)

結合流程圖了解,答案輸出為:async2 end => Promise => async1 end => promise1 => promise2 => setTimeout 但是,對于async/await ,我們有個細節還要處理一下。如下:

async/await執行順序

我們知道

async

隐式傳回 Promise 作為結果的函數,那麼可以簡單了解為,await後面的函數執行完畢時,await會産生一個微任務(Promise.then是微任務)。但是我們要注意這個微任務産生的時機,它是執行完await之後,直接跳出async函數,執行其他代碼(此處就是協程的運作,A暫停執行,控制權交給B)。其他代碼執行完畢後,再回到async函數去執行剩下的代碼,然後把await後面的代碼注冊到微任務隊列當中。我們來看個例子:

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')
 // 舊版輸出如下,但是請繼續看完本文下面的注意那裡,新版有改動
// script start => async2 end => Promise => script end => promise1 => promise2 => async1 end => setTimeout
           

複制

分析這段代碼:

  • 執行代碼,輸出

    script start

  • 執行async1(),會調用async2(),然後輸出

    async2 end

    ,此時将會保留async1函數的上下文,然後跳出async1函數。
  • 遇到setTimeout,産生一個宏任務
  • 執行Promise,輸出

    Promise

    。遇到then,産生第一個微任務
  • 繼續執行代碼,輸出

    script end

  • 代碼邏輯執行完畢(目前宏任務執行完畢),開始執行目前宏任務産生的微任務隊列,輸出

    promise1

    ,該微任務遇到then,産生一個新的微任務
  • 執行産生的微任務,輸出

    promise2

    ,目前微任務隊列執行完畢。執行權回到async1
  • 執行await,實際上會産生一個promise傳回,即
let promise_ = new Promise((resolve,reject){ resolve(undefined)})
           

複制

執行完成,執行await後面的語句,輸出

async1 end

  • 最後,執行下一個宏任務,即執行setTimeout,輸出

    setTimeout

注意

新版的chrome浏覽器中不是如上列印的,因為chrome優化了,await變得更快了,輸出為:

// script start => async2 end => Promise => script end => async1 end => promise1 => promise2 => setTimeout
           

複制

但是這種做法其實是違法了規範的,當然規範也是可以更改的,這是 V8 團隊的一個 PR ,目前新版列印已經修改。知乎上也有相關讨論,可以看看 https://www.zhihu.com/question/268007969

我們可以分2種情況來了解:

  1. 如果await 後面直接跟的為一個變量,比如:await 1;這種情況的話相當于直接把await後面的代碼注冊為一個微任務,可以簡單了解為promise.then(await下面的代碼)。然後跳出async1函數,執行其他代碼,當遇到promise函數的時候,會注冊promise.then()函數到微任務隊列,注意此時微任務隊列裡面已經存在await後面的微任務。是以這種情況會先執行await後面的代碼(async1 end),再執行async1函數後面注冊的微任務代碼(promise1,promise2)。
  2. 如果await後面跟的是一個異步函數的調用,比如上面的代碼,将代碼改成這樣:
console.log('script start')

async function async1() {
    await async2()
    console.log('async1 end')
}
async function async2() {
    console.log('async2 end')
    return Promise.resolve().then(()=>{
        console.log('async2 end1')
    })
}
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')
           

複制

輸出為:

// script start => async2 end => Promise => script end => async2 end1 => promise1 => promise2 => async1 end => setTimeout
           

複制

此時執行完awit并不先把await後面的代碼注冊到微任務隊列中去,而是執行完await之後,直接跳出async1函數,執行其他代碼。然後遇到promise的時候,把promise.then注冊為微任務。其他代碼執行完畢後,需要回到async1函數去執行剩下的代碼,然後把await後面的代碼注冊到微任務隊列當中,注意此時微任務隊列中是有之前注冊的微任務的。是以這種情況會先執行async1函數之外的微任務(promise1,promise2),然後才執行async1内注冊的微任務(async1 end). 可以了解為,這種情況下,await 後面的代碼會在本輪循環的最後被執行. 浏覽器中有事件循環,node 中也有,事件循環是 node 處理非阻塞 I/O 操作的機制,node中事件循環的實作是依靠的libuv引擎。由于 node 11 之後,事件循環的一些原理發生了變化,這裡就以新的标準去講,最後再列上變化點讓大家了解前因後果。

node 中的事件循環

浏覽器中有事件循環,node 中也有,事件循環是 node 處理非阻塞 I/O 操作的機制,node中事件循環的實作是依靠的libuv引擎。由于 node 11 之後,事件循環的一些原理發生了變化,這裡就以新的标準去講,最後再列上變化點讓大家了解前因後果。

宏任務和微任務

node 中也有宏任務和微任務,與浏覽器中的事件循環類似,其中,

macro-task 大概包括:

  • setTimeout
  • setInterval
  • setImmediate
  • script(整體代碼)
  • I/O 操作等。

micro-task 大概包括:

  • process.nextTick(與普通微任務有差別,在微任務隊列執行之前執行)
  • new Promise().then(回調)等。

node事件循環整體了解

先看一張官網的 node 事件循環簡化圖:

面試題:說說事件循環機制(滿分答案來了)

圖中的每個框被稱為事件循環機制的一個階段,每個階段都有一個 FIFO 隊列來執行回調。雖然每個階段都是特殊的,但通常情況下,當事件循環進入給定的階段時,它将執行特定于該階段的任何操作,然後執行該階段隊列中的回調,直到隊列用盡或最大回調數已執行。當該隊列已用盡或達到回調限制,事件循環将移動到下一階段。

是以,從上面這個簡化圖中,我們可以分析出 node 的事件循環的階段順序為:

輸入資料階段(incoming data)->輪詢階段(poll)->檢查階段(check)->關閉事件回調階段(close callback)->定時器檢測階段(timers)->I/O事件回調階段(I/O callbacks)->閑置階段(idle, prepare)->輪詢階段...

階段概述

  • 定時器檢測階段(timers):本階段執行 timer 的回調,即 setTimeout、setInterval 裡面的回調函數。
  • I/O事件回調階段(I/O callbacks):執行延遲到下一個循環疊代的 I/O 回調,即上一輪循環中未被執行的一些I/O回調。
  • 閑置階段(idle, prepare):僅系統内部使用。
  • 輪詢階段(poll):檢索新的 I/O 事件;執行與 I/O 相關的回調(幾乎所有情況下,除了關閉的回調函數,那些由計時器和 setImmediate() 排程的之外),其餘情況 node 将在适當的時候在此阻塞。
  • 檢查階段(check):setImmediate() 回調函數在這裡執行
  • 關閉事件回調階段(close callback):一些關閉的回調函數,如:socket.on('close', ...)。

三大重點階段

日常開發中的絕大部分異步任務都是在 poll、check、timers 這3個階段處理的,是以我們來重點看看。

timers

timers 階段會執行 setTimeout 和 setInterval 回調,并且是由 poll 階段控制的。同樣,在 Node 中定時器指定的時間也不是準确時間,隻能是盡快執行。

poll

poll 是一個至關重要的階段,poll 階段的執行邏輯流程圖如下:

面試題:說說事件循環機制(滿分答案來了)

如果目前已經存在定時器,而且有定時器到時間了,拿出來執行,eventLoop 将回到 timers 階段。

如果沒有定時器, 會去看回調函數隊列。

  • 如果 poll 隊列不為空,會周遊回調隊列并同步執行,直到隊列為空或者達到系統限制
  • 如果 poll 隊列為空時,會有兩件事發生
    • 如果有 setImmediate 回調需要執行,poll 階段會停止并且進入到 check 階段執行回調
    • 如果沒有 setImmediate 回調需要執行,會等待回調被加入到隊列中并立即執行回調,這裡同樣會有個逾時時間設定防止一直等待下去,一段時間後自動進入 check 階段。
check

check 階段。這是一個比較簡單的階段,直接執行 setImmdiate 的回調。

process.nextTick

process.nextTick 是一個獨立于 eventLoop 的任務隊列。

在每一個 eventLoop 階段完成後會去檢查 nextTick 隊列,如果裡面有任務,會讓這部分任務優先于微任務執行。

看一個例子:

setImmediate(() => {
    console.log('timeout1')
    Promise.resolve().then(() => console.log('promise resolve'))
    process.nextTick(() => console.log('next tick1'))
});
setImmediate(() => {
    console.log('timeout2')
    process.nextTick(() => console.log('next tick2'))
});
setImmediate(() => console.log('timeout3'));
setImmediate(() => console.log('timeout4'));
           

複制

  • 在 node11 之前,因為每一個 eventLoop 階段完成後會去檢查 nextTick 隊列,如果裡面有任務,會讓這部分任務優先于微任務執行,是以上述代碼是先進入 check 階段,執行所有 setImmediate,完成之後執行 nextTick 隊列,最後執行微任務隊列,是以輸出為

    timeout1=>timeout2=>timeout3=>timeout4=>next tick1=>next tick2=>promise resolve

  • 在 node11 之後,process.nextTick 是微任務的一種,是以上述代碼是先進入 check 階段,執行一個 setImmediate 宏任務,然後執行其微任務隊列,再執行下一個宏任務及其微任務,是以輸出為

    timeout1=>next tick1=>promise resolve=>timeout2=>next tick2=>timeout3=>timeout4

node 版本差異說明

這裡主要說明的是 node11 前後的差異,因為 node11 之後一些特性已經向浏覽器看齊了,總的變化一句話來說就是,如果是 node11 版本一旦執行一個階段裡的一個宏任務(setTimeout,setInterval和setImmediate)就立刻執行對應的微任務隊列,一起來看看吧~

timers 階段的執行時機變化

setTimeout(()=>{
    console.log('timer1')
    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)
setTimeout(()=>{
    console.log('timer2')
    Promise.resolve().then(function() {
        console.log('promise2')
    })
}, 0)
           

複制

  • 如果是 node11 版本一旦執行一個階段裡的一個宏任務(setTimeout,setInterval和setImmediate)就立刻執行微任務隊列,這就跟浏覽器端運作一緻,最後的結果為

    timer1=>promise1=>timer2=>promise2

  • 如果是 node10 及其之前版本要看第一個定時器執行完,第二個定時器是否在完成隊列中.
    • 如果是第二個定時器還未在完成隊列中,最後的結果為

      timer1=>promise1=>timer2=>promise2

    • 如果是第二個定時器已經在完成隊列中,則最後的結果為

      timer1=>timer2=>promise1=>promise2

check 階段的執行時機變化

setImmediate(() => console.log('immediate1'));
setImmediate(() => {
    console.log('immediate2')
    Promise.resolve().then(() => console.log('promise resolve'))
});
setImmediate(() => console.log('immediate3'));
setImmediate(() => console.log('immediate4'));
           

複制

  • 如果是 node11 後的版本,會輸出

    immediate1=>immediate2=>promise resolve=>immediate3=>immediate4

  • 如果是 node11 前的版本,會輸出

    immediate1=>immediate2=>immediate3=>immediate4=>promise resolve

nextTick 隊列的執行時機變化

setImmediate(() => console.log('timeout1'));
setImmediate(() => {
    console.log('timeout2')
    process.nextTick(() => console.log('next tick'))
});
setImmediate(() => console.log('timeout3'));
setImmediate(() => console.log('timeout4'));
           

複制

  • 如果是 node11 後的版本,會輸出

    timeout1=>timeout2=>next tick=>timeout3=>timeout4

  • 如果是 node11 前的版本,會輸出

    timeout1=>timeout2=>timeout3=>timeout4=>next tick

以上幾個例子,你應該就能清晰感受到它的變化了,反正記着一個結論,如果是 node11 版本一旦執行一個階段裡的一個宏任務(setTimeout,setInterval和setImmediate)就立刻執行對應的微任務隊列。

node 和 浏覽器 eventLoop的主要差別

兩者最主要的差別在于浏覽器中的微任務是在每個相應的宏任務中執行的,而nodejs中的微任務是在不同階段之間執行的。

思考拓展題

node的事件循環中,首次進入事件循環時,在poll階段,有可能會跳到check階段執行回調,但是check階段在poll階段之後,那麼poll階段是如何知道check階段有沒有回調需要執行的呢?

更多了解資料

  • 【語音解題系列】說說JS的事件循環機制 (含滿分答題技巧)
  • 【語音解題系列】說說JS的事件循環機制 (含滿分答題技巧)
  • 自測題目,https://github.com/LuckyWinty/fe-weekly-questions/issues

參考資料

  • 一道面試題引發的node事件循環深入思考
  • Node.js 事件循環,定時器和 process.nextTick()
  • 詳解JavaScript中的Event Loop(事件循環)機制
  • New Changes to the Timers and Microtasks in Node v11.0.0 ( and above)