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

起源

2019年了,相信你們對 Promise 和 async/await 都再也不陌生了。javascript

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

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

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

面試題

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

注:執行順序以 Chrome71 爲準git

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')
複製代碼

答案:github

script start
async1 start
async2
promise1
script end
promise2
async1 end
setTimeout
複製代碼

看完答案後,我與不少人同樣不管如何也不理解 爲何 async1 end 會晚於promise2 輸出……個人第一反應是 我對 await 的理解有誤差,因此我決心要把這個問題弄明白。面試

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

全部執行順序以 Chrome71 爲準,不討論 Babel 和 Promise 墊片。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 的描述:

以及 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 的理解

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

相關文章
相關標籤/搜索