在 Promise 原理解析中,我們介紹了怎么使用 Promise 來實現回調操作,使用 Promise 能很好地解決回調地獄的問題,但是這種方式充滿了 Promise 的 then() 方法,如果處理流程比較復雜的話,那么整段代碼將充斥着 then,語義化不明顯,代碼不能很好地表示執行流程。
比如下面這樣一個實際的使用場景:我先請求極客邦的內容,等返回信息之后,我再請求極客邦的另外一個資源。下面代碼展示的是使用 fetch 來實現這樣的需求,fetch 被定義在 window 對象中,可以用它來發起對遠程資源的請求,該方法返回的是一個 Promise 對象,這和我們上一篇文章中講的 XFetch 很像,只不過 fetch 是瀏覽器原生支持的,並沒有利用 XMLHttpRequest 來封裝。
fetch('https://www.geekbang.org') .then( (response) => { console.log(response) return fetch('https://www.geekbang.org/test') }).then( (response) => { console.log(response) }).catch( error => { console.log(error) })
從這段 Promise 代碼可以看出來,使用 promise.then 也是相當復雜,雖然整個請求流程已經線性化了,但是代碼里面包含了大量的 then 函數,使得代碼依然不是太容易閱讀。
基於這個原因,ES7 引入了 async/await,這是 JavaScript 異步編程的一個重大改進,提供了在不阻塞主線程的情況下使用同步代碼實現異步訪問資源的能力,並且使得代碼邏輯更加清晰。你可以參考下面這段代碼:
async function foo(){ try{ let response1 = await fetch('https://www.geekbang.org') console.log('response1', response1)
let response2 = await fetch('https://www.geekbang.org/test') console.log('response2', response2) }catch(err) { console.error(err) } } foo()
通過上面代碼,你會發現整個異步處理的邏輯都是使用同步代碼的方式來實現的,而且還支持 try catch 來捕獲異常,這就是完全在寫同步代碼,所以是非常符合人的線性思維的。但是很多人都習慣了異步回調的編程思維,對於這種采用同步代碼實現異步邏輯的方式,還需要一個轉換的過程,因為這中間隱藏了一些容易讓人迷惑的細節。
那么本篇文章我們繼續深入,看看 JavaScript 引擎是如何實現 async/await 的。如果上來直接介紹 async/await 的使用方式的話,那么你可能會有點懵,所以我們就從其最底層的技術點一步步往上講解,從而帶你徹底弄清楚 async 和 await 到底是怎么工作的。
本文我們首先介紹生成器(Generator)是如何工作的,接着講解 Generator 的底層實現機制 —— 協程(Coroutine);又因為 async/await 使用了 Generator 和 Promise 兩種技術,所以緊接着我們就通過 Generator 和 Promise 來分析 async/await 到底是如何以同步的方式來編寫異步代碼的。
一、生成器 VS 協程
我們先來看看什么是生成器函數?
生成器函數是一個帶星號函數,而且是可以暫停執行和恢復執行的。我們可以看下面這段代碼:
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 並不是一次執行完的,全局代碼和 genDemo 函數交替執行。其實這就是生成器函數的特性,可以暫停執行,也可以恢復執行。下面我們就來看看生成器函數的具體使用方式:
1. 在生成器函數內部執行一段代碼,如果遇到 yield 關鍵字,那么 JavaScript 引擎將返回關鍵字后面的內容給外部,並暫停該函數的執行。
2. 外部函數可以通過 next 方法恢復函數的執行。
關於函數的暫停和恢復,相信你一定很好奇這其中的原理,那么接下來我們就來簡單介紹下 JavaScript 引擎 V8 是如何實現一個函數的暫停和恢復的,這也會有助於你理解后面要介紹的 async/await。
要搞懂函數為何能暫停和恢復,那你首先要了解協程的概念。
協程是一種比線程更加輕量級的存在。你可以把協程看成是跑在線程上的任務,一個線程上可以存在多個協程,但是在線程上同時只能執行一個協程,比如當前執行的是 A 協程,要啟動 B 協程,那么 A 協程就需要將主線程的控制權交給 B 協程,這就體現在 A 協程暫停執行,B協程恢復執行;同樣,也可以從 B 協程中啟動 A 協程。通常,如果從 A 協程啟動 B 協程,我們就把 A 協程 稱為 B 協程的父協程。
正如一個進程可以擁有多個線程一樣,一個線程也可以擁有多個協程。最重要的是,協程不是被操作系統內核所管理,而完全是由程序所控制(也就是在用戶態執行)。這樣帶來的好處就是性能得到了很大的提升,不會像線程切換那樣消耗資源。
為了讓你更好地理解協程是怎么執行的,我結合上面那段代碼的執行過程,畫出了下面的 “協程執行流程圖”,你可以對照着代碼來分析:
從圖中可以看出來協程的四點規則:
1. 通過調用生成器函數 genDemo 來創建一個協程 gen,創建之后,gen 協程並沒有立即執行。
2. 要讓 gen 協程執行,需要通過調用 gen.next。
3. 當協程正在執行的時候,可以通過 yield 關鍵字來暫停 gen 協程的執行,並返回主要信息給父協程。
4. 如果協程在執行期間,遇到了 return 關鍵字,那么 JavaScript 引擎會結束當前協程,並將 return 后面的內容返回給父協程。
不過,對於上面這段代碼,你可能又有這樣疑問:父協程有自己的調用棧,gen 協程時也有自己的調用棧,當 gen 協程通過 yield 把控制權交給父協程時,V8 是如何切換到父協程的調用棧?當父協程通過 gen.next 恢復 gen 協程時,又是如何切換 gen 協程的調用棧?
要搞清楚上面的問題,你需要關注以下兩點內容。
(1)第一點:gen 協程和父協程是在主線程上交互執行的,並不是並發執行的,它們之間的切換是通過 yield 和 gen.next 來配合完成的。
(2)第二點:當在 gen 協程中調用了 yield 方法時,JavaScript 引擎會保存 gen 協程當前的調用棧信息,並恢復父協程的調用棧信息。同樣,當在父協程中執行 gen.next 時,JavaScript 引擎會保存父協程的調用棧信息,並恢復 gen 協程的調用棧信息。
為了直觀理解父協程和 gen 協程是如何切換調用棧的,你可以參考下圖:
到這里相信你已經弄清楚了協程是怎么工作的,其實在 JavaScript 中,生成器就是協程的一種實現方式,這樣相信你也就理解什么是生成器了。
那么接下來,我們使用生成器和 Promise 來改造開頭的那段 Promise 代碼。改造后的代碼如下所示:
//foo函數
function* foo() { let response1 = yield fetch('https://www.geekbang.org') console.log('response1') console.log(response1) let response2 = yield fetch('https://www.geekbang.org/test') console.log('response2') console.log(response2) } //執行foo函數的代碼
let gen = foo() function getGenPromise(gen) { return gen.next().value // gen.next() 會返回 {value, done} 配到 yield 關鍵字就停止
} getGenPromise(gen).then((response) => { console.log('response1') console.log(response) return getGenPromise(gen) }).then((response) => { console.log('response2') console.log(response) })
生成器函數執行順序圖:
從上圖一代碼可以看到,foo 函數是一個生成器函數,在 foo 函數里面實現了用同步代碼形式來實現異步操作;但是在 foo 函數外部,我們還需要寫一段執行 foo 函數的代碼,如上述代碼的后半部分所示,那下面我們就來分析下這段代碼是如何工作的。
(1)首先執行的是 let gen = foo(),創建了 gen 協程。
(2)然后在父協程中通過執行 gen.next 把主線程的控制權交給 gen 協程。
(3)gen 協程獲取到主線程的控制權后,就調用 fetch 函數創建了一個 Promise 對象 response1,然后通過 yield 暫停 gen 協程的執行,並將 response1 返回給父協程。
(4)父協程恢復執行后,調用 response1.then 方法等待請求結果。
(5)等通過 fetch 發起的請求完成之后,會調用 then 中的回調函數,then 中的回調函數拿到結果之后,通過調用 gen.next 放棄主線程的控制權,將控制權交給 gen 協程繼續執行下一個請求。
以上就是協程和 Promise 相互配合執行的一個大致流程。不過通常,我們把執行生成器的代碼封裝成一個函數,並把這個執行生成器代碼的函數稱為執行器(可參考著名的 co 框架),如下面這種方式:
function* foo() { let response1 = yield fetch('https://www.geekbang.org') console.log('response1') console.log(response1) let response2 = yield fetch('https://www.geekbang.org/test') console.log('response2') console.log(response2) } co(foo())
通過使用生成器配合執行器,就能實現使用同步的方式寫出異步代碼了,這樣也大大加強了代碼的可讀性。
二、async / await
雖然生成器已經能很好地滿足我們的需求了,但是程序員的追求是無止境的,這不又在 ES7 中引入了 async/await,這種方式能夠徹底告別執行器和生成器,實現更加直觀簡潔的代碼。其實 async/await 技術背后的秘密就是 Promise 和生成器應用,往底層說就是微任務和協程應用。要搞清楚 async 和 await 的工作原理,我們就得對 async 和 await 分開分析。
1、async
我們先來看看 async 到底是什么?根據 MDN 定義,async 是一個通過異步執行並隱式返回 Promise 作為結果的函數。
對 async 函數的理解,這里需要重點關注兩個詞:異步執行和隱式返回 Promise。
關於異步執行的原因,我們一會兒再分析。這里我們先來看看是如何隱式返回 Promise 的,你可以參考下面的代碼:
async function foo() { return 2 } console.log(foo()) // Promise {<resolved>: 2}
執行這段代碼,我們可以看到調用 async 聲明的 foo 函數返回了一個 Promise 對象,狀態是 resolved,返回結果如下所示:
Promise {<resolved>: 2}
2、await
我們知道了 async 函數返回的是一個 Promise 對象,那下面我們再結合文中這段代碼來看看 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 到底會發生什么。在詳細介紹之前,我們先站在協程的視角來看看這段代碼的整體執行流程圖:
結合上圖,我們來一起分析下 async/await 的執行流程。
(1)首先,執行 console.log(0) 這個語句,打印出來 0。
(2)緊接着就是執行 foo 函數,由於 foo 函數是被 async 標記過的,所以當進入該函數的時候,JavaScript 引擎會保存當前的調用棧等信息,然后執行 foo 函數中的 console.log(1) 語句,並打印出 1。
(3)接下來就執行到 foo 函數中的 await 100 這個語句了,這里是我們分析的重點,因為在執行 await 100 這個語句時,JavaScript 引擎在背后為我們默默做了太多的事情,那么下面我們就把這個語句拆開,來看看 JavaScript 到底都做了哪些事情。
3.1、當執行到 await 100 時,會默認創建一個 Promise 對象,代碼如下所示:
let promise_ = new Promise((resolve,reject){ resolve(100) })
3.2、在這個 promise_ 對象創建的過程中,我們可以看到在 executor 函數中調用了 resolve 函數,JavaScript 引擎會將該任務提交給微任務隊列。
3.3、然后 JavaScript 引擎會暫停當前協程的執行,將主線程的控制權轉交給父協程執行,同時會將 promise_ 對象返回給父協程。
3.4、主線程的控制權已經交給父協程了,這時候父協程要做的一件事是調用 promise_.then 來監控 promise 狀態的改變。
(4)接下來繼續執行父協程的流程,這里我們執行 console.log(3),並打印出來 3。
(5)隨后父協程將執行結束,在結束之前,會進入微任務的檢查點,然后執行微任務隊列,微任務隊列中有 resolve(100) 的任務等待執行,執行到這里的時候,會觸發 promise_.then 中的回調函數,如下所示:
promise_.then((value)=>{ //回調函數被激活后 //將主線程控制權交給foo協程,並將vaule值傳給協程 })
該回調函數被激活以后,會將主線程的控制權交給 foo 函數的協程,並同時將 value 值傳給該協程。
(6)foo 協程激活之后,會把剛才的 value 值賦給了變量 a,然后 foo 協程繼續執行后續語句,執行完成之后,將控制權歸還給父協程。
以上就是 async/await 的執行流程。正是因為 async 和 await 在背后為我們做了大量的工作,所以我們才能用同步的方式寫出異步代碼來。
三、總結
好了,今天就介紹到這里,下面我來總結下今天的主要內容。
Promise 的編程模型依然充斥着大量的 then 方法,雖然解決了 回調地獄 的問題,但是在語義方面依然存在缺陷,代碼中充斥着大量的 then 函數,這就是 async / await 出現的原因。
使用 async/await 可以實現用同步代碼的風格來編寫異步代碼,這是因為 async/await 的基礎技術使用了生成器和 Promise,生成器是協程的實現,利用生成器能實現生成器函數的暫停和恢復。
另外,V8 引擎還為 async/await 做了大量的語法層面包裝,所以了解隱藏在背后的代碼有助於加深你對 async/await 的理解。
async / await 無疑是異步編程領域非常大的一個革新,也是未來的一個主流的編程風格。其實,除了 JavaScript,Python、Dart、C# 等語言也都引入了 async / await,使用它不僅能讓代碼更加整潔美觀,而且還能確保該函數始終都能返回 Promise。
思考時間:下面這段代碼整合了定時器、Promise 和 async / await ,你能分析出來這段代碼執行后輸出的內容嗎?
async function foo() { console.log('foo') } async function bar() { console.log('bar start') await foo() console.log('bar end') } console.log('script start') setTimeout(function () { console.log('setTimeout') }, 0) bar(); new Promise(function (resolve) { console.log('promise executor') resolve(); }).then(function () { console.log('promise then') }) console.log('script end')
// 答案 script start bar start foo promise executor script end bar end promise then // promise then 和 bar end 輸出還存在問題 不同環境下,這兩個輸出的順序不同。 setTimeout
執行流程分析:
1、首先執行console.log('script start');打印出script start
2、接着遇到定時器,創建一個新的宏任務,放在延遲隊列中
3、緊接着執行bar函數,由於bar函數被async標記的,所以進入該函數時,JS引擎會保存當前調用棧等信息,然后執行bar函數中的console.log('bar start');語句,打印bar start。
4、接下來執行到bar函數中的await foo();語句,執行foo函數,也由於foo函數被async標記的,所以進入該函數時,JS引擎會保存當前調用棧等信息,然后執行foo函數中的console.log('foo');語句,打印foo。
5、執行到await foo()時,會默認創建一個Promise對象
6、在創建Promise對象過程中,調用了resolve()函數,且JS引擎將該任務交給微任務隊列
7、然后JS引擎會暫停當前協程的執行,將主線程的控制權交給父協程,同時將創建的Promise對象返回給父協程
8、主線程的控制權交給父協程后,父協程就調用該Promise對象的then()方法監控該Promise對象的狀態改變
9、接下來繼續父協程的流程,執行new Promise(),打印輸出promise executor,其中調用了 resolve 函數,JS引擎將該任務添加到微任務隊列隊尾
10、繼續執行父協程上的流程,執行console.log('script end');,打印出來script end
11、隨后父協程將執行結束,在結束前,會進入微任務檢查點,然后執行微任務隊列,微任務隊列中有兩個微任務等待執行,先執行第一個微任務,觸發第一個promise.then()中的回調函數,將主線程的控制權交給bar函數的協程,bar函數的協程激活后,繼續執行后續語句,執行 console.log('bar end');,打印輸出bar end
12、bar函數協程執行完成后,執行微任務隊列中的第二個微任務,觸發第二個promise.then()中的回調函數,該回調函數被激活后,執行console.log('promise then');,打印輸出promise then
13、執行完之后,將控制權歸還給主線程,當前任務執行完畢,返回undefined,並取出延遲隊列中的任務,執行console.log('setTimeout');,打印輸出setTimeout。
故:最終輸出順序是:script start => bar start => foo => promise executor => script end => bar end => promise then => setTimeout