面試題:說說事件循環機制

答題大綱

先說基本知識點,宏任務、微任務有哪些

說事件循環機制過程,邊說邊畫圖出來

說async/await執行順序注意,可以把 chrome 的優化,做法其實是違法了規范的,V8 團隊的PR這些自信點說出來,顯得你很好學,理解得很詳細,很透徹。

把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新特性)

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


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


結合流程圖理解,答案輸出為: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 functionasync2(){

????console.log('async2 end')

}

async1()

setTimeout(function(){

????console.log('setTimeout')

},0)

newPromise(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返回,即

letpromise_ =newPromise((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?,目前新版打印已經修改。 知乎上也有相關討論,可以看看?www.zhihu.com/question/26…

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

如果await 后面直接跟的為一個變量,比如:await 1;這種情況的話相當于直接把await后面的代碼注冊為一個微任務,可以簡單理解為promise.then(await下面的代碼)。然后跳出async1函數,執行其他代碼,當遇到promise函數的時候,會注冊promise.then()函數到微任務隊列,注意此時微任務隊列里面已經存在await后面的微任務。所以這種情況會先執行await后面的代碼(async1 end),再執行async1函數后面注冊的微任務代碼(promise1,promise2)。

如果await后面跟的是一個異步函數的調用,比如上面的代碼,將代碼改成這樣:

console.log('script start')asyncfunctionasync1(){awaitasync2()console.log('async1 end')}asyncfunctionasync2(){console.log('async2 end')returnPromise.resolve().then(()=>{console.log('async2 end1')? ? })}async1()setTimeout(function(){console.log('setTimeout')},0)newPromise(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中的微任務是在不同階段之間執行的。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容