js事件迴圈與macro&micro任務佇列-前端面試進階

腹黑的可樂發表於2022-11-23

背景

一天愜意的下午。朋友給我分享了一道頭條面試題,如下:

async function async1(){
    console.log('async1 start')
    await async2()
    console.log('async1 end')
}
async function async2(){
    console.log('async2')
}
console.log('script start')
setTimeout(function(){
    console.log('setTimeout') 
},0)  
async1();
new Promise(function(resolve){
    console.log('promise1')
    resolve();
}).then(function(){
    console.log('promise2')
})
console.log('script end')

這個題目主要是考察對同步任務、非同步任務:setTimeout、promise、async/await的執行順序的理解程度。(建議大家也自己先做一下o)

當時由於我對async、await瞭解的不是很清楚,答案錯的千奇百怪 :(),就不記錄了,然後我就去看文章理了理思路。現在寫在下面以供日後參考。

js事件輪詢的一些概念

這裡首先需要明白幾個概念:同步任務、非同步任務、任務佇列、microtask、macrotask

同步任務
指的是,在主執行緒上排隊執行的任務,只有前一個任務執行完畢,才能執行後一個任務;

非同步任務
指的是,不進入主執行緒、而進入"任務佇列"(task queue)的任務,等待同步任務執行完畢之後,輪詢執行非同步任務佇列中的任務

macrotask 即宏任務,宏任務佇列等同於我們常說的任務佇列,macrotask是由宿主環境分發的非同步任務,事件輪詢的時候總是一個一個任務佇列去檢視執行的,"任務佇列"是一個先進先出的資料結構,排在前面的事件,優先被主執行緒讀取。

microtask 即微任務,是由js引擎分發的任務,總是新增到當前任務佇列末尾執行。另外在處理microtask期間,如果有新新增的microtasks,也會被新增到佇列的末尾並執行。注意與setTimeout(fn,0)的區別:

setTimeOut(fn(),0)
指定某個任務在主執行緒最早可得的空閒時間執行,也就是說,儘可能早得執行。它在"任務佇列"的尾部新增一個事件,因此要等到同步任務和"任務佇列"現有的事件都處理完,才會得到執行。

總結一下:

task queue、microtask、macrotask

  • An event loop has one or more task queues.(task queue is macrotask queue)
  • Each event loop has a microtask queue.
  • task queue = macrotask queue != microtask queue
  • a task may be pushed into macrotask queue,or microtask queue
  • when a task is pushed into a queue(micro/macro),we mean preparing work is finished,so the task can be executed now.

所以我們可以得到js執行順序是:

開始 -> 取第一個task queue裡的任務執行(可以認為同步任務佇列是第一個task queue) -> 取microtask全部任務依次執行 -> 取下一個task queue裡的任務執行 -> 再次取出microtask全部任務執行 -> … 這樣迴圈往復

常見的一些宏任務和微任務:

macrotask:

  • setTimeout
  • setInterval
  • setImmediate
  • requestAnimationFrame
  • I/O
  • UI rendering

microtask:

  • process.nextTick
  • Promises
  • Object.observe
  • MutationObserver

Promise、Async、Await都是一種非同步解決方案

Promise是一個建構函式,呼叫的時候會生成Promise例項。當Promise的狀態改變時會呼叫then函式中定義的回撥函式。我們都知道這個回撥函式不會立刻執行,他是一個微任務會被新增到當前任務佇列中的末尾,在下一輪任務開始執行之前執行。

async/await成對出現,async標記的函式會返回一個Promise物件,可以使用then方法新增回撥函式。await後面的語句會同步執行。但 await 下面的語句會被當成微任務新增到當前任務佇列的末尾非同步執行。

我們來看一下答案

不記得題的!繼續往下看,溫馨的準備了題目,不用往上翻 :)

async function async1(){
    console.log('async1 start')
    await async2()
    console.log('async1 end')
}
async function async2(){
    console.log('async2')
}
console.log('script start')
setTimeout(function(){
    console.log('setTimeout') 
},0)  
async1();
new Promise(function(resolve){
    console.log('promise1')
    resolve();
}).then(function(){
    console.log('promise2')
})
console.log('script end')
=node10版本是這個結果: script start -> async1 start -> async2 -> promise1 -> script end -> promise2 -> async1 end -> setTimeout

<node10版本是這個結果: script start -> async1 start -> async2 -> promise1 -> script end -> async1 end -> promise2 -> setTimeout

按照上面寫的js執行順序就可以得到正確結果,但最後卻又存在兩個答案,為什麼會出現兩種結果呢?我們可以看到兩種結果中就是async1 end 和 Promise2之間的順序出現差別,我猜想是因為不同版本的node對await的執行方法不同,導致await下面的程式碼進入任務佇列的時間點不同。具體參見 如何在V8中最佳化JavaScript非同步程式設計? 裡面的《深入瞭解await》

簡單理解如下:

async function f(){
  await p
  console.log(1);
}
//node.js8及即將推廣的標準應該會解析成下面這樣
function f(){
  Promise.resolve(p).then(()=>{
    console.log(1)
  })
}
//其餘的版本應該會解析成下面的這樣
function f(){
  new Promise(resolve=>{
    resolve(p)
  }).then(()=>{
    console.log(1)
  })
}

正對上面的這兩種差異主要是:

  1. 當Promise.resolve 的引數為 promise 物件時直接返回這個 Promise 物件,then 函式在這個 Promise 物件發生改變後立刻執行。
  2. 舊版的解析 await 時會重新生成一個Promise物件。儘管該 promise 確定會 resolve 為 p,但這個過程本身是非同步的,也就是現在進入佇列的是新 promise 的 resolve 過程,所以該 promise 的 then 不會被立即呼叫,而要等到當前佇列執行到前述 resolve 過程才會被呼叫,然後再執行then函式。(下面的練習一下的例子會講解當resolve()引數為promise時會怎麼執行)

不用擔心這個題沒解,真相只有一個。根據 TC39 最近決議,await將直接使用 Promise.resolve() 相同語義。

最後我們以最新決議來分析這個題目的可能的執行過程(在Chrome環境下):

  • 定義函式async1、async2。輸出'script start'
  • 將 setTimeout 裡面的回撥函式(宏任務)新增到下一輪任務佇列。因為這段程式碼前面沒有執行任何的非同步操作且等待時間為0s。所以回撥函式會被立刻放到下一輪任務佇列的開頭。
  • 執行async1。我們知道async函式里面await標記之前的語句和 await 後面的語句是同步執行的。所以這裡先後輸出"async1 start",’async2 start‘.
  • 這時暫停執行下面的語句,下面的語句被放到當前佇列的最後。
  • 繼續執行同步任務。
  • 輸出 ‘Promise1’。將then裡面的函式放在當前佇列的最後。
  • 然後輸出‘script end’,注意這時只是同步任務執行完了,當前任務佇列的任務還沒有執行完畢,還有兩個微任務被新增進來了!佇列是先進先出的結構,所以這裡先輸出 ‘async1 end’ 再輸出 ‘Promise2’,這時第一輪任務佇列才真算執行完了。
  • 然後執行下一個任務列表的任務。執行setTimeout裡面的非同步函式。輸出‘setTimeout’。

練習一下

stackoverflow上的一道題目

let resolvePromise = new Promise(resolve => {
  let resolvedPromise = Promise.resolve()
  resolve(resolvedPromise)
})
resolvePromise.then(() => {
  console.log('resolvePromise resolved')
})
let resolvedPromiseThen = Promise.resolve().then(res => {
  console.log('promise1')
})
resolvedPromiseThen
  .then(() => {
    console.log('promise2')
  })
  .then(() => {
    console.log('promise3')
  })
結果:promise1 -> promise2 -> resolvePromise resolved -> promise3

這道題真的是非常費解了。為什麼'resolvePromise resolved'會在第三行才顯示呢?和舍友討論了一晚上無果。

其實這個題目的難點就在於resolve一個Promise物件,js引擎會怎麼處理。我們知道Promise.resolve()的引數為Promise物件時,會直接返回這個Promise物件。但當resolve()的引數為Promise物件時,情況會有所不同:

resolve(resolvedPromise)
//等同於:
Promise.resolve().then(() => resolvedPromise.then(resolve));

所以這裡第一次執行到這兒的時候:

  • 第一個then函式里面的() => resolvedPromise.then(resolve, reject)為microtask。會被放入當前任務列表的最後
  • 然後是Promise1被放入任務列表的最後。
  • 沒有同步操作了開始執行任務列表,這時因為resolvedPromise是一個已經resolved的Promise直接執行then函式,將then函式中的resole()函式放入當前佇列的最後,然後輸出Promise1。
  • 將Promise2放入佇列的最後。執行resole()
  • 這時的resolvePromise終於變成了一個resolved狀態的Promise物件了,將‘resolvePromise resolved’放入當前任務列表的最後。輸出Promise2。
  • 將Promise3放到當前任務佇列的最後。輸出resolvePromise resolved。最後輸出Promise3.

結束!這裡面的幾段程式碼是比較重要的,解釋了js會按照什麼樣的方式來執行這些新特性。

最後如果有誤,歡迎指正。

相關文章