圖與例解讀Async

概述

JavaScript ES7的async/await語法讓異步promise操作起來更方便。如果你需要從多個數據庫或者接口按順序異步獲取數據,你可能最終寫出一坨糾纏不清的promise與回調。然而使用async/await可以讓我們用更加可讀、可維護的方式來表達這種邏輯。

這篇教程以圖表與簡單例子來闡述JS async/await的語法與運行機理。

在深入之前,我們先簡單回顧一下promise,如果對這方面概念有自信,大可自行跳過。


Promise

在JS的世界裡,一個promise抽象表達一個非阻塞(阻塞指一個任務開始後,要等待該任務執行結果產生之後才繼續執行後續任務)的異步流程,類似於Java的Futrue或者C#的Task。

Promise最典型的使用場景是網絡或其他I/O操作(如讀取一個文件或者發送一個HTTP請求)。與其阻塞住當前的執行“線程”,我們可以產生一個異步的promise,然後用then方法來附加一個回調,用於執行該promise完成之後要做的事情。回調自身也可以返回一個promise,如此我就可以將多個promise串聯。

為方便說明,假定後續所有的例子都已經引入了request-promise庫:

var rp = require('request-promise');

然後我們就可以如此發送一個簡單的HTTP GET請求並獲得一個promise返回值:

const promise = rp('http://example.com/')

現在來看個例子:

console.log('Starting Execution');
const promise = rp('http://example.com/');
promise.then(result => console.log(result));
console.log("Can't know if promise has finished yet...");

我們在第3行產生了一個promise,然後在第4行附上了一個回調函數。返回的promise是異步的,所以當執行的第6行的時候,我們無法確定這個promise有沒有完成,多次執行可能有不同的結果(譯者:瀏覽器裡執行多少次,這裡promise都會是未完成狀態)。概括來說,promise之後的代碼跟promise自身是併發的(譯者:對這句話有異議者參見本文最後一節的併發說明)

並不存在一種方法可以讓當前的執行流程阻塞直到promise完成,這一點與Java的Futrue.get相異。JS裡,我們無法直接原地等promise完成,唯一可以用於提前計劃promise完成後的執行邏輯的方式就是通過then附加回調函數。

下面的圖表描繪了上面代碼例子的執行過程:

圖與例解讀Async/Await

Promise的執行過程,調用“線程”無法直接等待promise結果。唯一規劃promise之後邏輯的方法是使用then方法附加一個回調函數。

通過then 附加的回調函數只會在promise成功是被觸發,如果失敗了(比如網絡異常),這個回調不會執行,處理錯誤需要通過catch方法:

rp('http://example.com/').
then(() => console.log('Success')).
catch(e => console.log(`Failed: ${e}`))

最後,為了方便試驗功能,我們可以直接創建一些“假想”的promise,使用Promise.resolve生成會直接成功或失敗的promise結果:

圖與例解讀Async/Await


問題——組合多個Promise

只使用一個單次的promise非常簡單。然而如果我們需要編寫一個非常複雜了異步邏輯,我們可能需要將若干個promise組合起來。寫許多的then語句以及匿名函數很容易失控。

比如,我們需要實現以下邏輯:

  1. 發起一個HTTP請求,等待結果並將其輸出
  2. 再發起兩個併發的HTTP請求
  3. 當兩個請求都完成時,一起輸出他們

下面的代碼演示如何達到這個要求:

圖與例解讀Async/Await

我們先呼叫第一次HTTP請求,然後預備一個在它完成時執行的回調(第1-3行)。在回調裡,我們為另外兩次請求製造了promise(第8-9行)。這兩個promise併發運行,我們需要計劃一個在兩個都完成時執行的回調,於是,我們通過Promise.all(第11行)來講他們合併。這第一個回調的返回值是一個promise,我們再添加一個then來輸出結果(第12-16行)。

以下圖標描繪這個計算過程:

圖與例解讀Async/Await

將promise組合的計算過程。使用“Promise.all”將兩個併發的promise合併成一個。

為了一個簡單的例子,我們最終寫了兩個then回調以及一個Promise.all來同步兩個併發promise。如果我們還想再多做幾個異步操作或者添加一些錯誤處理會怎樣?這種實現方案最終很容變為糾纏成一坨的then、Promise.all以及回調匿名函數。


Async函數

一個async函數是定義會返回promise的函數的簡便寫法。

比如,以下兩個定義是等效的:

function f() {
return Promise.resolve('TEST');
}
// asyncF is equivalent to f!
async function asyncF() {
return 'TEST';
}

相似地,會拋出錯誤的async函數等效於返回將失敗的promise的函數:

function f() {
return Promise.reject('Error');
}
// asyncF is equivalent to f!
async function asyncF() {
throw 'Error';
}

Await

以前,當我們產生一個promise,我們無法同步地等待它完成,我們只能通過then註冊一個回調函數。不允許直接等待一個promise是為了鼓勵開發者寫非阻塞的代碼,不然開發者會更樂意寫阻塞的代碼,因為這樣比promise和回調簡單。

然而,為了同步多個promise,我們需要它們互相等待,換句話說,如果一個操作本身就是異步的(比如,用promise包裝的),它應該具備能力等待另一個異步操作先完成。但是JS解釋器如何知道一個操作是不是在一個promise裡的?

答案就是async關鍵字,所有的async函數一定會返回一個promise。所以,JS解釋器也可以確信async函數里操作是用promise包裝的異步過程。於是也就可以允許它等待其他promise。

鍵入await關鍵字,它只能在async函數內使用,讓我們可以等待一個promise。如果在async函數外使用promise,我們依然需要使用then和回調函數:

圖與例解讀Async/Await

現在我們來看看我們可以如何解決之前提到的問題:

圖與例解讀Async/Await

上面的片段,我們將邏輯分裝在一個async函數里。這樣我們就可以直接對promise使用await了,也就規避了寫then回調。最後我們調用這個async函數,然後按照普通的方式使用返回的promise。

要注意的是,在第一個例子裡(沒有async/await),後面兩個promise是併發的。所以我們在第7-8行也是如此,然後直到11-12行才用await來等待兩個promise都完成。這之後,我們可以確信兩個promise都已經完成(與之前Promise.all(...).then(...)類似)。

計算流程跟之前的圖表描繪的一樣,但是代碼變得更加已讀與直白。

事實上,async/await其實會翻譯成promise與then回調(譯者:babel其實是翻譯成generator語法,再通過類似co的函數運行,co內部運行機制離不開promise)。每次我們使用await,解釋器會創建一個promise然後把async函數的後續代碼放到then回調裡。

我們來看看以下的例子:

async function f() {
console.log('Starting F');
const result = await rp('http://example.com/');
console.log(result);
}

f函數的內在運行過程如下圖所描繪。因為f標記了async,它會與它的調用者“併發”:

圖與例解讀Async/Await

函數f啟動併產生一個promise。在這一刻,函數剩下的部分都會被封裝到一個回調函數里,並被計劃在promise完成之後執行。


錯誤處理

在之前的例子裡,我們大多假定promise會成功,然後await一個promise的返回值。如果我們等待的promise失敗了,會在async函數里產生一個異常,我們可以使用標準的try/catch來處理它:

圖與例解讀Async/Await

如果async函數不處理這個異常,不管是這異常是因為promise是被reject了還是其他的bug,這個函數都會返回一個被reject掉的promise:

圖與例解讀Async/Await

這就讓我們可以使用熟悉的方式來處理錯誤。


擴展說明

async/await是一個對promise進行補充的語法部件,它能讓我們寫更少的重複代碼來使用promise。然而,async/await並不能徹底取代普通的promise。比如,如果我們在一個普通的函數或者全局作用域裡使用一個async函數,我們無法使用await,也就只能求助於原始的promise用法:

async function fAsync() {
// actual return value is Promise.resolve(5)
return 5;
}
// can't call "await fAsync()". Need to use then/catch
fAsync().then(r => console.log(`result is ${r}`));

我通常會把大部分的異步邏輯封裝在一個或少量幾個async函數里,然後在非async的代碼區域裡使用,這樣就可以儘量減少書寫then或catch回調。

async / await是讓promise用起來更簡潔的語法糖。所有的async / await都可以用普通的promise來實現。所有總結來說,這只是個代碼樣式與簡潔的問題。

學院派的人會指出,併發與並行是有區別的(譯者:所以前文都是說併發,而非並行)。

併發是組合多個獨立過程來一起工作,並行是多個過程同時執行。併發是體現在應用的結構設計,並行是實際執行的方式。

我們來看看一個多線程應用的例子。將應用分割成多個線程是該應用併發模型的定義,將這些線程放到可用的cpu核心上執行是確立它的並行。一個併發的系統也可以在一個單核處理器上正常運行,但這種情況並不是並行。

圖與例解讀Async/Await

併發(concurrent)與並行(parallel)

以這種方式理解,promise可以將一個程序分解成多個併發的模塊,它們或許,也可能並不會並行執行。JS是否並行執行要看解釋器自身的實現。

比如,NodeJS是單線程的,如果一個promise裡有大量的CPU操作(非I/O操作),你可能感受不到太多並行。然而如果你用像nashorn這樣的工具把代碼編譯成java字節碼,理論上你可以把繁重的CPU操作放到其他內核上來獲得平行效果。

於是在我的觀點中,promise(不管是裸的還是有async/await)只是作用於定義JS應用的併發模型(而非確定邏輯是否會並行運行)。


分享到:


相關文章: