為了不讓使用者多等待一分一秒,為了讓應用程式能有更敏捷的反應,為了讓CPU不落入空轉,現代開發者必然得面對的課題之一,就是非同步程式設計,從低階的多執行緒模式開始,中間曾歷經了回呼、Promise、產生器,以及async、await等各種非同步模型,開發者應認識這些模型的演進目的各是為何,才能瞭解它們的適用場合,以及撰寫時應採取的語意,而不是僅止於API層面的運用。
從Promise說起
實際上,「非同步」是個沒有精確定義的名詞,一般的描述是「執行一件任務,但不想等待,希望在任務完成時獲得通知,以便進行下一步流程」,不想等待的原因是,CPU這段時間可以去執行其他的任務,以免浪費了執行效能,因此,希望進行的非同步任務,往往是一些會阻斷流程的操作,通常是I/O密集型任務,像是檔案讀取、資料庫操作、網路下載等,當然,也可能是耗時運算,在過去,這類任務若不是太複雜的話,可透過基本的執行緒或並行API來實現。
然而,JavaScript在前後端應用的普及,成了各式非同步應用的濫觴,從最早的回呼模式開始,又有了Continuation-passing風格,以及後來更廣為接受的Promise模式,而Promise模式的風行,更連帶使得其他語言也有了類似的API實現,像是guava-libraries的ListenableFuture,或者是JDK8中的CompletableFuture,這些模式在先前專欄〈非同步操作的多種模式〉也曾經做過介紹。
Promise出現的原因,就開發者一般的理解,是為了解決回呼地獄,而造成程式碼難以閱讀的問題,將巢狀的回呼函式,改以readFilePromise(...).then(callback1).then(callback2).then(callback3)這類循序的風格來撰寫,就可以用看似同步的程式碼流程,來撰寫出非同步的執行效果,由於這類風格廣為接受,為了讓API在實作時能有個共通的標準,CommonJS制定了Promises/A規範,後來更基於該規範進一步改進,而有了Promises/A+規範。
不少程式庫是基於Promises/A或Promises/A+規範來實作(儘管兩者在狀態、術語或API上有些不同),像是jQuery,由於一些歷史性的原因,jQuery 1.x與2.x實作遵循的是Promises/A提案,而jQuery 3.x遵循Promises/A+規範,而在ECMAScript 6中,更將Promise納入了語言規範之中,遵循的是Promises/A+的提案。例如在ES6中,可以如下實現一個readFile的功能,並在各自讀取檔案後顯示結果:
function readFile(fileName) { return new Promise((resolve, reject) => { // 實際讀取檔案 resolve(content); }); } readFile('file1').then((content) => { console.log(content); }) readFile('file2').then((content) => { console.log(content); })
產生器與非同步
在ES6中,也新增了產生器的語法,有關產生器的介紹,可以參考我先前專欄〈產生器與惰性求值〉的內容。就ES6的語法來說的話,一個函式可以使用*標示它會是個產生器,如果函式需要「傳回」或「返回」結果,可使用yield而不是return,此時會回到呼叫者原有的流程,若呼叫者呼叫了產生器的next()方法,流程又會再度回到產生器上次呼叫yield的地方並執行後續程式碼。
從這點來看,產生器就像是個有多出入口的函式,就呼叫者與被呼叫者的角度來看,兩個流程彼此協同進行,實際上,這樣的機制被稱之為協程(Coroutine),而產生器經常被認為是一種半協程(semicoroutine)。由於每次回到產生器流程時,才會進行下一次值的運算,因此產生器在過去,經常被用在需要惰性求值的場合,用以增加程式執行的效率。
由於產生器在yield的時候,流程可回到呼叫者手中,這意謂著,透過適當的設計,在某些阻斷或耗時操作進行時可執行yield,以回到呼叫者流程,讓呼叫者可以從事其他任務,因而可作為一種實現非同步的方式。舉例來說,可以基於上頭範例中的readFile,實作出以下的產生器:
function * a_task() { var content1 = yield readFile('file1'); console.log(content1); var content2 = yield readFile('file2'); console.log(content2); }
就程式碼的簡潔與可讀性來說,這個程式好了許多,而且不單是風格看來像是循序,a_task函式的執行也是循序的——yield之後,流程會離開a_task(這時可以去做些別的任務),等到有結果了,讓流程回到上次yield之處並繼續往下執行;相對地,先前readFile('file1').then(...)之後,實際上,流程就直接往下一步進行了,開發者撰寫程式時,因此還是要意識到這個非同步的事實。
語義更清楚的async、await
在上頭的範例中,a_task函式實際會是由另一個設計好的流程來呼叫,有些程式庫做了些不錯的封裝,像是co(https://github.com/tj/co)程式庫,而在ECMAScript的下個版本,也就是ES7中有了個標準的提案,新增了async、await兩個關鍵字,就目前可見的草案來看,若在readFile前加上async,也就是成為async function readFile(fileName)的話,它就成了一個非同步函式(必須傳回Promise)。
另一方面,await可以等待一個Promise物件獲得resolve,取得其結果並傳回,也就是說,先前的a_task,若依現在ES7的草案說明,可以改寫成:
async function a_task() { var content1 = await readFile('file1'); console.log(content1); var content2 = await readFile('file2'); console.log(content2); }
與使用*及yield相比,async與await的標示顯然更清楚了。實際上,在Python中也有著類似的發展歷程,在Python 3.4時,有個yield from語法,可用來與asyncio模組搭配,實作出基於產生器的非同步功能,然而語義不夠清楚,因此在Python 3.5中增加了async、await加以取代,yield from基於相容而暫時存在,然而不建議使用。
獲取一致的模型
在過去,處理非同步模型時,開發者總得進入另一種思考模式,儘管Promise解決了回呼地獄的問題,讓程式碼能像是循序般的外觀,然而,實際上還是要顧慮到一些非同步的行為,每當思考著現在是同步還是非同步,總是會讓開發者頭痛的不得了。
有了async、await,乍看似乎又多了一個要讓人頭痛(與學習)的API,然而,實際上,是讓開發者有機會使用一致的模型,來同時面對同步與非同步流程的撰寫。
因為,Promise在需要明確區分出非同步流程時,還是有其空間,然而,若想要完全從循序的角度來撰寫程式,async、await就會是種選擇。