異步編程的終極解決方案 async-await:用同步的方式去寫異步代碼
早期的回調函數
回調函數我們經常有寫到,比如:
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
生成器就是協程的一種實現方式。
成熟方案 Promise
關於 Promise
,可以去看我上一篇文章:《異步編程 Promise:從使用到手寫實現(4200 字長文)》,在這一篇文章中詳細介紹了 Promise
如何解決回調地獄的問題,瞭解 Promise
和微任務的淵源,然後帶你一步一步的解構手寫實現一個簡單的 Promise
,最後簡單介紹並手寫實現了一些 Promise
的 API,包括 Promise.all
、Promise.allSettled
、Promise.race
、Promise.finally
等 API。
終極解決方案 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
生成器最大的特點是可以控制函數的執行,是協程的一種實現方式。 -
Promise
的更多內容可以看我的這篇文章:《異步編程 Promise:從使用到手寫實現(4200 字長文)》:https://juejin.cn/post/6978419919582920740 -
async
/await
可以算是異步編程的終極解決方案,它通過同步的方式寫異步代碼,可以把await
看作是讓出線程的標誌,先去執行async
函數外部的代碼,等調用棧爲空再回來調用await
後面的代碼。
關於本文
來源:起風了 Q
https://juejin.cn/post/6978689182809997320
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/V8wdoaIzODnvWiwk2ylXuQ