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

Jialiang_T發表於2019-01-16

起源

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 的描述:

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

以及 PromiseResolveThenableJob:

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

總結:

  • 對於一個物件 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 的理解

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

相關文章