用 Generator 實現 JS 非同步流程控制

Natumsol發表於2016-12-26

前言

當初,JavaScript 引入非同步(Asynchonrous)主要是為了解決瀏覽器端同步IO造成的UI假死現象,但是主流的程式語言和Web伺服器都採取同步IO的模式,原因無非是:

  1. 採用同步IO編寫的程式碼符合人和直覺,程式碼容易編寫和維護。
  2. 對於同步IO造成的執行緒阻塞可以通過建立多執行緒(程式)的方式,通過增加伺服器數量進行橫向擴充套件來解決。

但是,在很多情況下,這種方式並不能很好地解決問題。比如對於靜態資源伺服器(CDN伺服器)來說,每時每刻要處理大量的檔案請求,如果對於每個請求都新開一個執行緒(程式),可想而知,效能開銷是很大的,而且有種殺雞用牛刀的感覺。所以Nginx採用了和JavaScript相同的策略來解決這個問題——單執行緒、非阻塞、非同步IO。這樣,當一個 IO 操作開始的時候,Nginx 不會等待操作完成就會去處理下一個請求,等到某個 IO 操作完成後,Nginx 再回過頭去處理(回撥)這次 IO 的後續工作。

然後2009年NodeJS的釋出,又極大的推進了非同步IO在服務端的應用,據聞,NodeJS在處理阿里巴巴“雙十一”的海量請求高併發中發揮了很大的作用。

所以,非同步IO真是個好東西,但是,編寫非同步程式碼卻有一個無法迴避的問題——回撥函式巢狀太多、過多的回撥層級造成閱讀和維護上的困難——俗稱“回撥地獄(callback hell)”。

螢幕快照 2016-11-21 下午2.37.50

為了解決這個難題,出現了各種各樣的解決方案。最先出來的方案是利用任務佇列控制非同步流程,著名的代表有Async。然後Promises/A+規範出來了,人們根據這個規範實現了Q。那時候AsyncQ各佔邊壁江山,兩邊都有不少忠實的擁躉, 雖然它們解決問題的思路不同,但是都很好的解決了地獄回撥的問題。隨著ES6(ECMA Script 6)Promise標準納入旗下,Promise成了真正意義上解決地獄回撥的最佳解決方案(在支援ES6的環境中,開箱即用,不用引入第三方庫)。

但是,雖然AsyncPromise之流都在程式碼層面避免了地獄回撥,但是程式碼組織結構上並沒有完全擺脫非同步的影子,和純同步的寫法相比,還是有很大的不同,寫起來還是略麻煩。幸好,ES6引入了Generator的概念,利用Generator就可以很好的解決這個問題了。

用 Generator 實現 JS 非同步流程控制

可以看到,經過Generator重寫後,程式碼形式上和我們熟悉的同步程式碼沒什麼二樣了。?

下面我們就來介紹這種神奇的黑魔法!

Generator 簡介

GeneratorES6新引進的關鍵字,它用來定義一個Generator,用法和定義一個普通的函式(function)幾乎一樣,只是在function關鍵字和函式名之前加入了星號 *Generator最大的特點就是定義的函式可以被暫停執行,很類似我們打斷點除錯程式碼:點Run按鈕程式碼就自動執行當前語句直到遇到下一個斷點並暫停,不同的是Generator的這種暫停態和執行態是由程式碼來定義和控制的。

Generator裡,yield關鍵字用來定義程式碼暫停的地方,類似於給程式碼打斷點(但不是真的打斷點,不要和debugger關鍵字混淆),而generator.next(value)則用來控制程式碼的執行並處理輸入輸出。下面用程式碼來說明:

這是一個利用Generator實現的自然數生成器。通過例項化一個Generator,然後每次通過gen.next()取得一個自然數,此過程可以無限進行下去。不過值得注意的是,通過gen.next()取得的輸出是一個物件,包含valuedone兩個屬性,其中value是真正返回的值,而done則用來標識Generator是否已經執行完畢。因為自然數生成器是一個無限迴圈,所以不存在done: true的情況。

這個例子比較簡單,下面來個稍微複雜點的例子(涉及到輸入和輸出)。

有人可能會對執行結果有疑問,不清楚外部的資料是如何傳到Generator內部的,可能會猜想是通過gen.next("西")這句話傳進去的,但是問題又來了,為什麼'部', '世', '界' 都傳進去了,'西'去哪了?別急,且聽我慢慢道來。

首先我們要明白yield其實由兩個動作組成,輸入 + 輸出(輸入在輸出前面),每次執行next,程式碼會暫停在yield 輸出執行後,其它的語句不再執行(很重要)。其次對於上面的例子來說,兩次next()才真正執行完一次while迴圈。比如上面的例子裡,為什麼第一次輸出的是[],而不是['西']呢?那是因為第一次執行gen.next("西")的時候,首先會將'西'傳進去,但是並沒有接受的物件,雖然西確實是被傳進來了,但是最後被丟棄了;然後程式碼執行完yield array輸出之後就暫停。然後第二次執行gen.next("部")的時候,會先執行輸入操作,執行array.push('部'), 然後進行第二次迴圈,執行輸出操作。

現在總結一下:

  1. 每個yield將程式碼分割成兩個部分,需要執行兩次next才能執行完。
  2. yield其實由兩個動作組成,輸入 + 輸出(輸入在輸出前面),每次執行next,程式碼會暫停在yield 輸出執行後,其它的語句不再執行(很重要)。

如何利用Generator進行非同步流程控制?

利用Generaotr可以暫停程式碼執行的特性,我們通過將非同步操作用yield關鍵字進行修飾,每當執行非同步操作的時候,程式碼便在此暫停執行了。非同步操作結束後,通過在回撥函式裡利用next(data)來控制Generator的執行流程,並順便將非同步操作的結果data回傳給Generator,執行下一步。到此,整個非同步流程得到了完美的控制,我們可以看一個小例子

可以看到,Generator確實可以幫助我們來控制非同步流程,但是上面的代比較很raw,存在以下兩個問題:

  • 不能自動執行,需要手動啟動。
  • 不能流程控制的程式碼需要自己寫在非同步回撥函式裡,且沒有通用性。

所以我們需要構造一個執行器,自動處理上面提到的兩個問題。

TJ大神的co就是用來解決這個問題的。

下面我來詳細說一下解決此問題的兩種方法:利用ThunkPromise

利用Thunk來構造generator自動執行器

這裡引入了一個新的概念——thunk( 讀音 [θʌŋk] ),為了幫助理解,下面單獨來介紹一下thunk。

Thunk

維基百科上的介紹如下:

In computer programming, a thunk is a subroutine that is created, often automatically, to assist a call to another subroutine. Thunks are primarily used to represent an additional calculation that a subroutine needs to execute, or to call a routine that does not support the usual calling mechanism. They have a variety of other applications to compiler code generation) and in modular programming.

可以簡單理解為,thunk就是為了滿足函式(子程式)呼叫的特殊需要,對原函式(子程式)進行了特殊的改造,主要用在編譯器的程式碼生成(傳名呼叫)和模組化程式設計中。

JavaScript中的thunk化指的是將多引數函式,將其替換成單引數的版本,且只接受回撥函式作為引數,比如NodeJsfs.readFile函式,tnunk化為:

為了接下來的方便,我們在這裡先構造一個thunkify函式,專門對函式進行thunk化:

執行器

現在開始構造我們的執行器。思路也很簡單,執行器接受一個Generator函式,例項化一個Generator物件,然後啟動任務,通過next()取得返回值,這個返回值其實是一個函式,提供了一個入口可以讓我們可以方便的注入控住邏輯,包括:控制Generator向下執行、將非同步執行的結果返回給Generator

測試

下面我們來測試一下程式碼是否按照我們的預期執行。

可以看到,完全符合我們的預期!

缺陷

雖然以上Thunk函式能完美實現我們對非同步流程的控制,但是對於同步任務卻不能正確的做出反應,比如我寫一個同步版的readFileSync

然後將var readFile = thunkify(_readFile); // 將_readFile thunk化改為var readFile = thunkify(_readFileSync)其餘均保持不變,執行程式碼會發現執行不成功。什麼原因造成的呢?其實只要仔細分析就會發現,主要問題主要出現在thunkify函式上面,在流程控制函式注入之前,任務函式就已經執行了,如果這個任務是非同步的,那沒問題,因為非同步任務回撥函式只會等主執行緒空閒了才會執行,所以非同步任務能確保控制函式能夠被成功注入。但是如果這個任務是同步的,那就不一樣了。傳給同步任務的回撥函式會被立刻執行,之後給它注入控制邏輯已經沒用了,因為同步任務早已執行完。

改進

為了改進thunkify函式,讓它能適應同步的情況,可以考慮將任務函式的執行延後到控制邏輯注入後執行,這樣就能確保無論任務函式非同步也好,同步也罷,都能注入控制邏輯。

改進後的:

利用Promise來構造generator自動執行器

有了上面的基礎,基於Promise就會容易理解多了。

toPromise

首先,我們應該將非同步任務改造成Promsie的形式,為了相容同步任務,我們先對任務進行thunkify統一化,然後再轉化為Promise。

執行器

因為Promise是標準化的,所以構造Promise的執行器比較簡單,我就直接show code了:

測試

經測試,完全符合要求。

小結

非同步流程的控制一直是JavaScript比較令人頭疼的一點,Generator的出現無疑是一件囍事,相信隨著ES6的普及以及ES7的推進(ES 7的asyncawait),非同步程式碼那反直覺的編寫方式將一去不復返,編寫和維護非同步程式碼將會越來越容易,JavaScript也將會越來越成熟,受到越來越多人的喜愛。

參考文獻

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!

任選一種支付方式

用 Generator 實現 JS 非同步流程控制 用 Generator 實現 JS 非同步流程控制

相關文章