令人費解的 async/await 執行順序

原文發佈在掘金社區:https://juejin.im/post/5c3cc981f265da616a47e028

起源

2019年了,相信大家對 Promise 和 async/await 都不再陌生了。

前幾日,我在社區讀到了一篇關於 async/await 執行順序的文章《「前端面試題系列1」今日頭條 面試題和思路解析》。文中提到了一道“2017年「今日頭條」的前端面試題”,還有另一篇對此題的解析文章《8張圖讓你一步步看清 async/await 和 promise 的執行順序》,兩文中都對問題進行了分析。不過在我看來,這兩篇文章都沒有把這個問題說清楚,同時在評論區中也有很多朋友留言表達了自己的疑惑

其實解決這個問題最關鍵的是以下兩點:

  1. Promise.resolve(v) 不等於 new Promise(resolve => resolve(v))
  2. 瀏覽器怎樣處理 new Promise(resolve => resolve(thenable)),即在 Promise 中 resolve 一個 thenable 對象

面試題

國際慣例,先給出面試題和答案:

注:執行順序以 Chrome71 爲準
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')

答案:

script start
async1 start
async2
promise1
script end
promise2
async1 end
setTimeout

看完答案後,我與很多人一樣無論如何也不理解 爲什麼 async1 end 會晚於promise2 輸出……我的第一反應是 我對 await 的理解有偏差,所以我決心要把這個問題弄明白。

本文主要解釋瀏覽器對 await 的處理,**並一步步將原題代碼轉換爲原生Promsie實現。

所有執行順序以 Chrome71 爲準,不討論 Babel 和 Promise 墊片。

第一次發文,難免有一些不嚴謹之處,如有錯誤,還望大家在評論區批評指正!

基礎

在解釋答案之前,你需要先掌握:

  • Promise 基礎

    • Promise 執行器中的代碼會被同步調用
    • Promise 回調是基於微任務的
  • 瀏覽器 eventloop
  • 宏任務與微任務的優先級

    • 宏任務的優先級高於微任務
    • 每一個宏任務執行完畢都必須將當前的微任務隊列清空
    • 第一個 script 標籤的代碼是第一個宏任務

主要內容

問題主要涉及以下4點:

  1. Promise 的鏈式 then() 是怎樣執行的
  2. async 函數的返回值
  3. await 做了什麼
  4. PromiseResolveThenableJob:瀏覽器對 new Promise(resolve => resolve(thenable)) 的處理

下面,讓我們一步步將原題中的代碼轉換爲更容易理解的等價代碼。

Promise 的鏈式 then() 是怎樣執行的

在正式開始之前,我們先來看以下這段代碼:

new Promise((r) => {
    r();
})
.then(() => console.log(1))
.then(() => console.log(2))
.then(() => console.log(3))

new Promise((r) => {
    r();
})
.then(() => console.log(4))
.then(() => console.log(5))
.then(() => console.log(6))

答案:

1
4
2
5
3
6

如果你得出的答案是 1 2 3 4 5 6 那說明你還沒有很好的理解 Promise.prototype.then()

爲什麼要先放出這段代碼?

因爲 async/await可視爲 Promise 的語法糖,同樣基於微任務實現;本題主要糾結的點在於 await 到底做了什麼導致 async1 end 晚於 promise2 輸出。問題的關鍵在於其執行過程中的微任務數量,下文中我們需要用上述代碼中的方式對微任務的執行順序進行標記,以輔助我們理解這其中的執行過程。

分析

  • Promise 多個 then() 鏈式調用,並不是連續的創建了多個微任務並推入微任務隊列,因爲 then() 的返回值必然是一個 Promise,而後續的 then() 是上一步 then() 返回的 Promise 的回調
  • 傳入 Promise 構造器的執行器函數內部的同步代碼執行到 resolve(),將 Promise 的狀態改變爲 <resolved>: undefined, 然後 then 中傳入的回調函數 console.log('1') 作爲一個微任務被推入微任務隊列
  • 第二個 then() 中傳入的回調函數 console.log('2') 此時還沒有被推入微任務隊列,只有上一個 then() 中的 console.log('1') 執行完畢後,console.log('2') 纔會被推入微任務隊列

總結

  • Promise.prototype.then() 會隱式返回一個新 Promise
  • 如果 Promise 的狀態是 pending,那麼 then 會在該 Promise 上註冊一個回調,當其狀態發生變化時,對應的回調將作爲一個微任務被推入微任務隊列
  • 如果 Promise 的狀態已經是 fulfilled 或 rejected,那麼 then() 會立即創建一個微任務,將傳入的對應的回調推入微任務隊列

爲了更好的解析問題,下面我對原題代碼進行一些修改,剔除和主要問題無關的代碼

<轉換1>:

async function async1() {
    console.log('async1 start')
    await async2()
    console.log('async1 end')
}
    
async function async2() {
    console.log('async2')
}
    
async1();
    
new Promise((resolve) => {
    console.log(1)
    resolve()
}).then(() => {
    console.log(2)
}).then(() => {
    console.log(3)
}).then(() => {
    console.log(4)
})

答案:

async1 start
async2
1
2
3
async1 end
4

我們剔除了 setTimeout 和一些同步代碼,然後爲 Promisethen 鏈增加了一個回調,而最終結果中 async1 end 在 3 後輸出,而不是在 2 後!

await 一定是做了一些我們不理解的“詭異操作”,令其後續代碼 console.log('async1 end') 被推遲了2個時序。

換句話說,async/await 是 Promise 的語法糖,同樣基於微任務實現,不可能有其他超出我們理解的東西,所以可以斷定:console.log('async1 end') 執行前,額外執行了2個微任務,所以導致被推遲2個時序!

如果你無法理解上面這段話,沒關係,請繼續向下看。

async 函數的返回值

下面解釋 async 關鍵字做了什麼:

  • 被 async 操作符修飾的函數必然返回一個 Promise
  • 當 async 函數返回一個值時,Promise 的 resolve 方法負責傳遞這個值
  • 當 async 函數拋出異常時,Promise 的 reject 方法會傳遞這個異常值

下面以原題中的函數 async2 爲例,作等價轉換

<轉換2>:

function async2(){
  console.log('async2');
  return Promise.resolve();
}

await 操作符做了什麼

這裏需要引入 TC39 規範

TC39 Await

規範晦澀難懂,我們可以看看這篇文章:《「譯」更快的 async 函數和 promises》,下面引入其中的一些描述:

簡單說,await v 初始化步驟有以下組成:

  1. 把 v 轉成一個 promise(跟在 await 後面的)。
  2. 綁定處理函數用於後期恢復。
  3. 暫停 async 函數並返回 implicit_promise 給調用者。

我們一步步來看,假設 await 後是一個 promise,且最終已完成狀態的值是 42。然後,引擎會創建一個新的 promise 並且把 await 後的值作爲 resolve 的值。藉助標準裏的 PromiseResolveThenableJob 這些 promise 會被放到下個週期執行。

結合規範和這篇文章,簡單總結一下,對於 await v

  • await 後的值 v 會被轉換爲 Promise
  • 即使 v 是一個已經 fulfilled 的 Promise,還是會新建一個 Promise,並在這個新 Promise 中 resolve(v)
  • await v 後續的代碼的執行類似於傳入 then() 中的回調

如此,可進一步對原題中的 async1 作等價轉換

<轉換3>:

function async1(){
  console.log('async1 start')
  return new Promise(resolve => resolve(async2()))
    .then(() => {
      console.log('async1 end')
    });
}

至此,我們根據規範綜合以上所有等價轉換,將 async/await 全部轉換爲原生 Promise 實現,其執行順序在 Chrome71 上與一開始給出的 <轉換1> 完全一致:

<轉換4>:

function async1(){
  console.log('async1 start')
  return new Promise(resolve => resolve(async2()))
    .then(() => {
      console.log('async1 end')
    });
}
    
function async2(){
  console.log('async2');
  return Promise.resolve();
}
    
async1();
    
new Promise((resolve) => {
    console.log(1)
    resolve()
}).then(() => {
    console.log(2)
}).then(() => {
    console.log(3)
}).then(() => {
    console.log(4)
})

到了這,你是不是感覺整個思路變清晰了?不過,還是不能很好的解釋 爲什麼 console.log('async1 end') 在3後面輸出,下面將說明其中的原因。

PromiseResolveThenableJob:瀏覽器對 new Promise(resolve => resolve(thenable)) 的處理

仔細觀察 <轉換4> 中的 async1 函數,不難發現 return new Promise(resolve => resolve(async2())) 中,Promise resolve 的是 async2(),而 async2() 返回了一個狀態爲 <resolved>: undefined 的 Promsie,Promise 是一個 thenable 對象

對於 thenable 對象,《ECMAScript 6 入門》中這樣描述:

thenable 對象指的是具有then方法的對象,比如下面這個對象

let thenable = {
    then: function(resolve, reject) {
        resolve(42);
    }
};

下面需要引入 TC39 規範中對 Promise Resolve Functions 的描述:

Promise Resolve Functions

以及 PromiseResolveThenableJob:

PromiseResolveThenableJob

總結:

  • 對於一個對象 o,如果 o.then 是一個 function,那麼 o 就可以被稱爲 thenable 對象
  • 對於 new Promise(resolve => resolve(thenable)),即“在 Promise 中 resolve 一個 thenable 對象”,需要先將 thenable 轉化爲 Promsie,然後立即調用 thenable 的 then 方法,並且 這個過程需要作爲一個 job 加入微任務隊列,以保證對 then 方法的解析發生在其他上下文代碼的解析之後

下面給出示例:

let thenable = {
  then(resolve, reject) {
    console.log('in thenable');
    resolve(100);
  }
};

new Promise((r) => {
  console.log('in p0');
  r(thenable);
})
.then(() => { console.log('thenable ok') })

new Promise((r) => {
  console.log('in p1');
  r();
})
.then(() => { console.log('1') })
.then(() => { console.log('2') })
.then(() => { console.log('3') })
.then(() => { console.log('4') });

執行順序:

in p0
in p1
in thenable
1
thenable ok
2
3
4

解析

  • in thenable 後於 in p1 而先於 1 輸出,同時 thenable ok1 後輸出
  • 在執行完同步任務後,微任務隊列中只有2個微任務:第一個是 轉換thenable爲Promise的過程,即 PromiseResolveThenableJob,第二個是 console.log('1')
  • 在 PromiseResolveThenableJob 執行中會執行 thenable.then(),從而註冊了另一個微任務:console.log('thenable ok')
  • 正是由於規範中對 thenable 的處理需要在一個微任務中完成,從而導致了第一個 Promise 的後續回調被延後了1個時序

如果在 Promise 中 resolve 一個 Promise 實例呢?

  1. 由於 Promise 實例是一個對象,其原型上有 then 方法,所以這也是一個 thenable 對象。
  2. 同樣的,瀏覽器會創建一個 PromiseResolveThenableJob 去處理這個 Promise 實例,這是一個微任務
  3. 在 PromiseResolveThenableJob 執行中,執行了 Promise.prototype.then,而這時 Promise 如果已經是 resolved 狀態 ,then 的執行會再一次創建了一個微任務

最終結果就是:額外創建了兩個Job,表現上就是後續代碼被推遲了2個時序

最終轉換

上面圍繞規範說了那麼多,不知你有沒有理解這其中的執行過程。規範是晦澀難懂的,下面我們結合規範繼續對代碼作“轉換”,讓這個過程變得更容易理解一些

對於代碼

new Promise((resolve) => {
    resolve(thenable)
})

在執行順序上等價於(我只敢說“在執行順序上等價”,因爲瀏覽器的內部實現無法簡單的模擬):

new Promise((resolve) => {
    Promise.resolve().then(() => {
        thenable.then(resolve)
    })
})

所以,原題中的 new Promise(resolve => resolve(async2())),在執行順序上等價於:

new Promise((resolve) => {
    Promise.resolve().then(() => {
        async2().then(resolve)
    })
})

綜上,給出最終轉換:

<轉換-END>

function async1(){
    console.log('async1 start');
    const p = async2();
    return new Promise((resolve) => {
        Promise.resolve().then(() => {
            p.then(resolve)
        })
    })
    .then(() => {
        console.log('async1 end')
    });
}
    
function async2(){
    console.log('async2');
    return Promise.resolve();
}
    
async1();
    
new Promise((resolve) => {
    console.log(1)
    resolve()
}).then(() => {
    console.log(2)
}).then(() => {
    console.log(3)
}).then(() => {
    console.log(4)
})

OK, 看到這裏,你應該理解了爲什麼在 Chrome71 中 async1 end 在 3 後輸出了。

不過這還沒完呢,認真的你可能已經發現,這裏給出的執行順序在 Chrome73 上不對啊。沒錯,這是因爲 Await 規範更新了……

Await 規範的更新

如果你在 Chrome73 中運行這道題的代碼,你會發現,執行順序與 Chrome71 中不同,這又是爲什麼?

我來簡單說說這個事情的過程:

在 Chrome71 之前的某個版本,nodejs 中有個 bug,這個 bug 的表現就是對 await 進行了激進優化,所謂激進優化,就是沒有按照 TC39 規範的要求執行。V8 團隊修復了這個 bug。不過,從這個 bug 中 V8 團隊得到了啓發,發現這個 bug 中的激進優化竟然可以帶來性能提升,所以向 TC39 提交了改進方案,並會在下個版本中執行這個優化……

上文中提到的譯文《「譯」更快的 async 函數和 promises》,說的就是這個優化的由來。

激進優化

文章中的“激進優化”,是指 await v 在語義上將等價於 Promise.resolve(v),而不再是現在的 new Promise(resolve => resolve(v)),所以在未來的 Chrome73 中,題中的代碼可做如下等價轉換:

<轉換-優化版本>

function async1(){
    console.log('async1 start');
    const p = async2();
    return Promise.resolve(p)
        .then(() => {
            console.log('async1 end')
        });
}
    
function async2(){
    console.log('async2');
    return Promise.resolve();
}
    
async1();
    
new Promise((resolve) => {
    console.log(1)
    resolve()
}).then(() => {
    console.log(2)
}).then(() => {
    console.log(3)
}).then(() => {
    console.log(4)
})

執行順序:

async1 start
async2
1
async1 end
2
3
4

有沒有覺得優化後的版本更容易理解了呢?

還需要補充的要點

  1. Promise.resolve(v) 不等於 new Promise(r => r(v)),因爲如果 v 是一個 Promise 對象,前者會直接返回 v,而後者需要經過一系列的處理(主要是 PromiseResolveThenableJob)
  2. 宏任務的優先級是高於微任務的,而原題中的 setTimeout 所創建的宏任務可視爲 第二個宏任務,第一個宏任務是這段程序本身

總結

本文從一道大家都熟悉的面試題出發,綜合了 TC39 規範和《「譯」更快的 async 函數和 promises》這篇文章對瀏覽器中的 async/await 的執行過程進行了分析,並給出了基於原生 Promise 實現的等價代碼。同時,引出了即將進行的性能優化,並簡單介紹了該優化的由來。

我要感謝在 SF 社區中與我一同追尋答案的 @xianshenglu,以上全部分析過程的詳細討論在這裏:async await 和 promise微任務執行順序問題

最後:

我在偶然中看到了這個問題,由於答案令人難以理解,所以我決定搞個明白,然後便一發不可收拾……

你可能會覺得這種在工作中根本不會遇到的代碼沒必要費這麼大力氣去分析,但通過以上的學習過程我還是收穫了一些知識的,這顛覆了我之前對 async/await 的理解

不得不說,遇到這種問題,還是得看規範才能搞明白啊……

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章