非同步程式設計的終極解決方案 async/await:用同步的方式去寫非同步程式碼

李不要熬夜發表於2021-08-11

早期回撥函式

回撥函式我們經常有寫到,比如:

ajax(url, (res) => {
  console.log(res);
})

但是這種回撥函式有一個大缺陷,就是會寫出 回撥地獄(Callback hell)。比如,如果多個回撥存在依賴,可能會寫成:

ajax(url, (res) => {
  console.log(res);
  // ...處理程式碼
  ajax(url2, (res2) => {
    console.log(res2);
    // ...處理程式碼
    ajax(url3, (res3) => {
      console.log(res3);
      // ...處理程式碼
    })
  })
})

這個就是回撥地獄:

  • 內嵌函式存在耦合,牽一髮而動全身,改一個會影響其它地方
  • 內嵌函式多了,發生錯誤要怎麼處理呢?這是一個難題

早期回撥函式的優缺點:

  • 優點:解決了 同步阻塞 的問題(只要有一個任務耗時很長,後面的任務都必須排隊等著,會拖延整個程式的執行)
  • 缺點:回撥地獄;不能用 try catch 捕獲錯誤;不能 return

過渡方案 Generator

ES6 新引入了 Generator 函式(生成器函式),可以透過 yield 關鍵字,把函式的執行流掛起,為改變執行流程提供了可能,從而為非同步程式設計提供解決方案。最大的特點就是可以控制函式的執行。Generator 有兩個區分於普通函式的部分:

  • 一是在 function後面,函式名之前有個 *,用來表示函式為 Generator 函式
  • 函式內部有 yield 表示式,用來定義函式內部的狀態

Generator 函式的具體使用方式是:

  • 在 Generator 函式內部執行一段程式碼,如果遇到 yield 關鍵字,那麼 JS 引擎將返回關鍵字後面的內容給外部,並暫停該函式的執行。
  • 外部函式可以透過 next 方法恢復函式的執行。

    function* fn() {
    console.log("one");
    yield '1';
    console.log("two");
    yield '2';
    console.log("three");
    return '3';
    }

    呼叫 Generator 函式和呼叫普通函式一樣,在函式名後面加上 () 即可,但是 Generator 函式不會像普通函式一樣立即執行,而是 返回一個指向內部狀態物件的指標,所以要呼叫遍歷器物件 Iterator 的 next 方法,指標就會從函式頭部或者上一次停下來的地方開始執行。如下:


next 方法:一般情況下, next 方法不傳入引數的時候,yield 表示式的返回值是 undefined。當 next 傳入引數的時候,該引數會作為上一步 yield 的返回值。Generator 生成器也是透過同步的方式寫非同步程式碼的,也可以解決回撥地獄的問題,但是比較難以理解,希望下面的例子能夠幫助你理解 Generator 生成器:

function* sum(a) {
  console.log('a:', a);
  let b = yield 1;
  console.log('b:', b);
  let c = yield 2;
  console.log('c:', c);
  let sum = a + b + c;
  console.log('sum:', sum)
  return sum;
}
  • next 不傳參時,yield 返回 undefined

  • 當第一次執行 next 時,傳參會被忽略,並且函式暫停在 yield 1 處,所以返回 1
  • 當第二次執行 next 時,不傳參,那麼 yield 1 返回的是 undefined ,所以 b 的值是 undefined
  • 第三次同理,c 的值為 undefined
  • 當 next 傳入引數時,該引數會作為上一步 yield 的返回值

如下圖:

  • 當第一次執行 next 時,傳參(20)會被忽略,並且函式暫停在 yield 1 處,所以返回 1
  • 當第二次執行 next 時,傳參 30,作為 yield 1 返回的值,所以 b = yield 1,b 的值是 30
  • 當第二次執行 next 時,傳參 40,作為 yield 2 返回的值,所以 c = yield 2, c 的值是 40

協程

我們知道,async/await 是一個自動執行的 Generator 函式,上面已經介紹了 Generator 函式,那麼接下來很有必要介紹一下 V8 引擎是如何實現一個函式的暫停和恢復 的呢?要搞懂函式為何能暫停和恢復,首先要了解 協程 的概念。程式和執行緒我們都知道,那麼協程是什麼呢?

協程是一種比執行緒更加輕量級的存在。可以把協程看成是跑線上程上的任務,一個執行緒上可以存在多個協程,但是線上程上同時只能執行一個協程,比如當前執行的是 A 協程,要啟動 B 協程,那麼 A 協程就需要將主執行緒的控制權交給 B 協程,這就體現在 A 協程暫停執行,B 協程恢復執行;同樣,也可以從 B 協程中啟動 A 協程。通常,如果從 A 協程啟動 B 協程,我們就把 A 協程稱為 B 協程的父協程。

正如一個程式可以擁有多個執行緒一樣,一個執行緒也可以擁有多個協程。最重要的是,協程不是被作業系統核心所管理,而是完全由程式所控制(即在使用者態執行)。這樣帶來的好處就是效能得到了很大的提升,不會像執行緒切換那樣消耗資源。可以結合程式碼理解:

function* genDemo() {
  console.log("開始執行第一段")
  yield 'generator 2'

  console.log("開始執行第二段")
  yield 'generator 2'

  console.log("開始執行第三段")
  yield 'generator 2'

  console.log("執行結束")
  return 'generator 2'
}

console.log('main 0')
let gen = genDemo()
console.log(gen.next().value)
console.log('main 1')
console.log(gen.next().value)
console.log('main 2')
console.log(gen.next().value)
console.log('main 3')
console.log(gen.next().value)
console.log('main 4')

執行過程如下圖所示,可以重點關注協程之間的切換:


從圖中可以看出來協程的四點規則:

  • 透過呼叫生成器函式 genDemo 來建立一個 協程 gen,建立之後,gen 協程並沒有立即執行。
  • 要讓 gen 協程執行,需要透過呼叫 gen.next。
  • 當協程正在執行的時候,可以 透過 yield 關鍵字來暫停 gen 協程的執行,並返回主要資訊給父協程。
  • 如果協程在執行期間,遇到了 return 關鍵字,那麼 JS 引擎會結束當前協程,並將 return 後面的內容返回給父協程。

協程之間的切換:

  • gen 協程和父協程是在主執行緒上互動執行的,並不是併發執行的,它們之前的切換是 透過 yield 和 gen.next 來配合完成 的。
  • 當在 gen 協程中呼叫了 yield 方法時,JS 引擎會儲存 gen 協程當前的呼叫棧資訊,並恢復父協程的呼叫棧資訊。同樣,當在父協程中執行 gen.next 時,JS 引擎會儲存父協程的呼叫棧資訊,並恢復 gen 協程的呼叫棧資訊。


其實在 JS 中,Generator 生成器就是協程的一種實現方式。

終極解決方案 async/await

使用 Promise 能很好地解決回撥地獄的問題,但是這種方式充滿了 Promise 的 then() 方法,如果處理流程比較複雜的話,那麼整段程式碼將充斥著 then,語義化不明顯,程式碼不能很好地表示執行流程。基於這個原因,ES7 引入了 async/await,這是 JavaScript 非同步程式設計的一個重大改進,提供了 在不阻塞主執行緒的情況下使用同步程式碼實現非同步訪問資源的能力,並且使得程式碼邏輯更加清晰。

其實 async/await 技術背後的秘密就是 Promise 和 Generator 生成器應用,往低層說就是 微任務和協程應用。要搞清楚 async 和 await 的工作原理,我們得對 async 和 await 分開分析。

async
async 到底是什麼?根據 MDN 定義,async 是一個透過 非同步執行並隱式返回 Promise 作為結果的函式。重點關注兩個詞:非同步執行和隱式返回 Promise。先來看看是如何隱式返回 Promise 的,參考下面的程式碼:

async function async1() {
  return '秀兒';
}
console.log(async1()); // Promise {<fulfilled>: "秀兒"}

執行這段程式碼,可以看到呼叫 async 宣告的 async1 函式返回了一個 Promise 物件,狀態是 resolved,返回結果如下所示:Promise {<fulfilled>: "秀兒"}。和 Promise 的鏈式呼叫 then 中處理返回值一樣。

await
await 需要跟 async 搭配使用,結合下面這段程式碼來看看 await 到底是什麼:

async function foo() {
  console.log(1)
  let a = await 100
  console.log(a)
  console.log(2)
}
console.log(0)
foo()
console.log(3)

站在 協程 的視角來看看這段程式碼的整體執行流程圖:


結合上圖來分析 async/await 的執行流程:

  • 首先,執行 console.log(0) 這個語句,列印出來 0。
  • 緊接著就是執行 foo 函式,由於 foo 函式是被 async 標記過的,所以當進入該函式的時候,JS 引擎會儲存當前的呼叫棧等資訊,然後執行 foo 函式中的 console.log(1) 語句,並列印出 1。
  • 當執行到 await 100 時,會預設建立一個 Promise 物件
  • 程式碼如下所示:let promise_ = new Promise((resolve,reject){ resolve(100) })
  • 在這個 promise_ 物件建立的過程中,可以看到在 executor 函式中呼叫了 resolve 函式,JS 引擎會將該任務提交給微任務佇列。
  • 然後 JS 引擎會暫停當前協程的執行,將主執行緒的控制權轉交給父協程執行,同時會將 promise_ 物件返回給父協程。
  • 主執行緒的控制權已經交給父協程了,這時候父協程要做的一件事是呼叫 promise_.then 來監控 promise 狀態的改變。
  • 接下來繼續執行父協程的流程,執行 console.log(3),並列印出來 3。
  • 隨後父協程將執行結束,在結束之前,會進入微任務的檢查點,然後執行微任務佇列,微任務佇列中有 resolve(100) 的任務等待執行,執行到這裡的時候,會觸發 promise_.then 中的回撥函式,如下所示:

    promise_.then((value) => {
    // 回撥函式被啟用後
    // 將主執行緒控制權交給foo協程,並將vaule值傳給協程
    })
  • 該回撥函式被啟用以後,會將主執行緒的控制權交給 foo 函式的協程,並同時將 value 值傳給該協程。
  • foo 協程啟用之後,會把剛才的 value 值賦給了變數 a,然後 foo 協程繼續執行後續語句,執行完成之後,將控制權歸還給父協程。

以上就是 await/async 的執行流程。正是因為 async 和 await 在背後做了大量的工作,所以我們才能用同步的方式寫出非同步程式碼來。當然也存在一些缺點,因為 await 將非同步程式碼改造成了同步程式碼,如果多個非同步程式碼沒有依賴性卻使用了 await 會導致效能上的降低。

async/await總結

  • Promise 的程式設計模型依然充斥著大量的 then 方法,雖然解決了回撥地獄的問題,但是在語義方面依然存在缺陷,程式碼中充斥著大量的 then 函式,這就是 async/await 出現的原因。
  • 使用 async/await 可以實現用同步程式碼的風格來編寫非同步程式碼,這是因為 async/await 的基礎技術使用了 Generator 生成器和 Promise,Generator 生成器是協程的實現,利用 Generator 生成器能實現生成器函式的暫停和恢復。
  • 另外,V8 引擎還為 async/await 做了大量的語法層面包裝,所以瞭解隱藏在背後的程式碼有助於加深你對 async/await 的理解。
  • async/await 無疑是非同步程式設計領域非常大的一個革新,也是未來的一個主流的程式設計風格。其實,除了 JavaScript,Python、Dart、C# 等語言也都引入了 async/await,使用它不僅能讓程式碼更加整潔美觀,而且還能確保該函式始終都能返回 Promise。

非同步程式設計總結

  • 早期的非同步回撥函式雖然解決了同步阻塞的問題,但是容易寫出回撥地獄。
  • Generator 生成器最大的特點是可以控制函式的執行,是協程的一種實現方式。
  • async/await 可以算是非同步程式設計的終極解決方案,它透過同步的方式寫非同步程式碼,可以把 await 看作是讓出執行緒的標誌,先去執行 async 函式外部的程式碼,等呼叫棧為空再回來呼叫 await 後面的程式碼。

相關文章