天天看點

Swift 并行程式設計現狀和展望 - async/await 和參與者模式

2016-12-20

• 能工巧匠集

CPU 速度已經很多年沒有大的突破了,硬體行業更多地将重點放在多核心技術上,而與之對應,軟體中并行程式設計的概念也越來越重要。如何利用多核心 CPU,以及擁有密集計算單元的 GPU,來進行快速的處理和計算,是很多開發者十分感興趣的事情。在今年年初 Swift 4 的展望中,Swift 項目的負責人 Chris Lattern 表示可能并不會這麼快提供語言層級的并行程式設計支援,不過最近 Chris 又在 IBM 的一次關于編譯器的分享中明确提到,有很大可能會在 Swift 5 中添加語言級别的并行特性。

這對 Swift 生态是一個好消息,也是一個大消息。不過這其實并不是什麼新鮮的事情,甚至可以說是一門現代語言發展的必經路徑和必備特性。因為 Objective-C/Swift 現在缺乏這方面的内容,是以很多專注于 iOS 的開發者對并行程式設計會很陌生。我在這篇文章裡結合 Swift 現狀簡單介紹了一些這門語言裡并行程式設計可能的使用方式,希望能幫助大家初窺門徑。(雖然我自己也還摸不到門徑在何方…)

Swift 現在沒有語言層面的并行機制,不過我們确實有一些基于庫的線程排程的方案,來進行并行操作。

雖然恍如隔世,不過 GCD (Grand Central Dispatch) 确實是從 iOS 4 才開始走進我們的視野的。在 GCD 和 block 被加入之前,我們想要新開一個線程需要用到 ​<code>​NSThread​</code>​​ 或者 ​<code>​NSOperation​</code>​​,然後使用 delegate 的方式來接收回調。這種書寫方式太過古老,也相當麻煩,容易出錯。GCD 為我們帶來了一套很簡單的 API,可以讓我們線上程中進行排程。在很長一段時間裡,這套 API 成為了 iOS 中多線程程式設計的主流方式。Swift 繼承了這套 API,并且在 Swift 3 中将它們重新導入為了更符合 Swift 文法習慣的形式。現在我們可以将一個操作很容易地派發到背景進行,首先建立一個背景隊列,然後調用 ​<code>​async​</code>​ 并傳入需要執行的閉包即可:

在 ​<code>​async​</code>​ 的閉包中,我們還可以繼續進行派發,最常見的用法就是開一個背景線程進行耗時操作 (從網絡擷取資料,或者 I/O 等),然後在資料準備完成後,回到主線程更新 UI:

當然,現在估計已經不會有人再這麼做網絡請求了。我們可以使用專門的 ​<code>​URLSession​</code>​​ 來進行通路。​<code>​URLSession​</code>​​ 和對應的 ​<code>​dataTask​</code>​ 會将網絡請求派發到背景線程,我們不再需要顯式對其指定。不過更新 UI 的工作還是需要回到主線程:

基于閉包模型的方式,不論是直接派發還是通過 ​<code>​URLSession​</code>​ 的封裝進行操作,都面臨一個嚴重的問題。這個問題最早在 JavaScript 中臭名昭著,那就是回調地獄 (callback hell)。

試想一下我們如果有一系列需要依次進行的網絡操作:先進行登入,然後使用傳回的 token 擷取使用者資訊,接下來通過使用者 ID 擷取好友清單,最後對某個好友點贊。使用傳統的閉包方式,這段代碼會是這樣:

這已經是使用了尾随閉包特性簡化後的代碼了,如果使用完整的閉包形式的話,你會看到一大堆 ​<code>​})​</code>​​ 堆疊起來。​<code>​else​</code>​ 路徑上幾乎不可能确定對應關系,而對于成功的代碼路徑來說,你也需要很多額外的精力來了解這些代碼。一旦這種基于閉包的回調太多,并嵌套起來,閱讀它們的時候就好似身陷地獄。

不幸的是,在 Cocoa 架構中我們似乎對此沒太多好辦法。不過我們确實有很多方法來解決回調地獄的問題,其中最成功的應該是 Promise 或者 Future 的方案。

在深入 Promise 或 Future 之前,我們先來将上面的回調做一些整理。可以看到,所有的請求在回調時都包含了兩個輸入值,一個是像 ​<code>​token​</code>​​,​<code>​user​</code>​​ 這樣我們接下來會使用到的結果,另一個是代表錯誤的 ​<code>​err​</code>​。我們可以建立一個泛型類型來代表它們:

重構 ​<code>​send​</code>​ 方法接收的回調類型後,上面的 API 調用就可以變為:

看起來并沒有什麼改善,對麼?我們隻不過使用一堆 ​<code>​({})​</code>​​ 的地獄換成了 ​<code>​switch...case​</code>​的地獄。但是,我們如果将 request 包裝一下,情況就會完全不同。

我們這裡沒有給出 ​<code>​Promise​</code>​​ 的具體實作,而隻是給出了概念性的說明。​<code>​Promise​</code>​​ 是一個泛型類型,它的初始化方法接受一個以 ​<code>​fulfill​</code>​​ 和 ​<code>​reject​</code>​​ 作為參數的函數作為參數 (一開始這可能有點拗口,你可以結合代碼再讀一次)。這個類型裡還提供了 ​<code>​then​</code>​​ 和 ​<code>​catch​</code>​​ 方法,​<code>​then​</code>​​ 方法的參數是另一個閉包,在 ​<code>​fulfill​</code>​​ 被調用時,我們可以執行這個閉包,并傳回新的 ​<code>​Promise​</code>​​ (之後會看到具體的使用例子):而在 ​<code>​reject​</code>​​ 被調用時,通過 ​<code>​catch​</code>​ 方法中斷這個過程。

在接下來的 ​<code>​Request​</code>​​ 的擴充中,我們定義了一個傳回 ​<code>​Promise​</code>​​ 的計算屬性,它将初始化一個内容類型為 ​<code>​Response​</code>​​ 的 ​<code>​Promise​</code>​​ (這裡的 ​<code>​Response​</code>​​ 是定義在 ​<code>​Request​</code>​​協定中的代表該請求對應的響應的類型,想了解更多相關的内容,可以看看我之前的一篇使用面向協定程式設計的文章)。我們在 ​<code>​.success​</code>​​ 時調用 ​<code>​fulfill​</code>​​,在 ​<code>​.failure​</code>​​ 時調用 ​<code>​reject​</code>​。

現在,上面的回調地獄可以用 ​<code>​then​</code>​​ 和 ​<code>​catch​</code>​ 的形式進行展平了:

​<code>​Promise​</code>​​ 本質上就是一個對閉包或者說 ​<code>​Result​</code>​​ 類型的封裝,它将未來可能的結果所對應的閉包先存儲起來,然後當确實得到結果 (比如網絡請求傳回) 的時候,再執行對應的閉包。通過使用 ​<code>​then​</code>​​,我們可以避免閉包的重疊嵌套,而是使用調用鍊的方式将異步操作串接起來。​<code>​Future​</code>​​ 和 ​<code>​Promise​</code>​​ 其實是同樣思想的不同命名,兩者基本指代的是一件事兒。在 Swift 中,有一些封裝得很好的第三方庫,可以讓我們以這樣的方式來書寫代碼,​​PromiseKit​​​ 和 ​​BrightFutures​​ 就是其中的佼佼者,它們确實能幫助避免回調地獄的問題,讓嵌套的異步代碼變得整潔。

雖然 Promise/Future 的方式能解決一部分問題,但是我們看看上面的代碼,依然有不少問題。

我們用了很多并不直覺的操作,對于每個 request,我們都生成了額外的 ​<code>​Promise​</code>​​,并用 ​<code>​then​</code>​ 串聯。這些其實都是模闆代碼,應該可以被更好地解決。

各個 ​<code>​then​</code>​ 閉包中的值隻在自己固定的作用域中有效,這有時候很不友善。比如如果我們的 ​<code>​LikeFriend​</code>​ 請求需要同時發送目前使用者的 token 的話,我們隻能在最外層添加臨時變量來持有這些結果:

Swift 内建的 throw 的錯誤處理方式并不能很好地和這裡的 ​<code>​Result​</code>​​ 和 ​<code>​catch { error in ... }​</code>​ 的方式合作。Swift throw 是一種同步的錯誤處理方式,如果想要在異步世界中使用這種的話,會顯得格格不入。文法上有不少了解的困難,代碼也會迅速變得十分醜陋。

如果從語言層面着手的話,這些問題都是可以被解決的。如果對微軟技術棧有所關心的同學應該知道,早在 2012 年 C# 5.0 釋出時,就包含了一個讓業界驚為天人的特性,那就是 ​<code>​async​</code>​​ 和 ​<code>​await​</code>​ 關鍵字。這兩個關鍵字可以讓我們用類似同步的書寫方式來寫異步代碼,這讓思維模型變得十分簡單。Swift 5 中有望引入類似的文法結構,如果我們有 async/await,我們上面的例子将會變成這樣的形式:

注意,以上代碼是根據現在 Swift 文法,對如果存在 ​<code>​async​</code>​​ 和 ​<code>​await​</code>​​ 時語言的形式的推測。雖然這不代表今後 Swift 中異步程式設計模型就是這樣,或者說 ​<code>​async​</code>​​ 和 ​<code>​await​</code>​ 就是這樣使用,但是應該代表了一個被其他語言驗證過的可行方向。

按照注釋的編号,進行一些簡單的說明:

這就是我們通常的 ​<code>​@IBAction​</code>​​,點選後執行 ​<code>​doSomething​</code>​。

​<code>​doSomething​</code>​​ 被 ​<code>​async​</code>​​ 關鍵字修飾,表示這是一個異步方法。​<code>​async​</code>​​ 關鍵字所做的事情隻有一件,那就是允許在這個方法内使用 ​<code>​await​</code>​​ 關鍵字來等待一個長時間操作完成。在這個方法裡的語句将被以同步方式執行,直到遇到第一個 ​<code>​await​</code>​。控制台将會列印 “Doing something…“。

遇到的第一個 await。此時這個 ​<code>​doSomething​</code>​​ 方法将進入等待狀态,該方法将會“傳回”,也即離開棧域。接下來 ​<code>​bunttonPressed​</code>​​ 中 ​<code>​doSomething​</code>​ 調用之後的語句将被執行,控制台列印 “Button Pressed”。

​<code>​token​</code>​​,​<code>​user​</code>​​,​<code>​friends​</code>​​ 和 ​<code>​result​</code>​​ 将被依次 ​<code>​await​</code>​​ 執行,直到獲得最終結果,并進行 ​<code>​updateUI​</code>​。

理論上 ​<code>​await​</code>​​ 關鍵字在語義上應該包含 ​<code>​throws​</code>​​,是以我們需要将它們包裹在 ​<code>​do...catch​</code>​​ 中,而且可以使用 Swift 内建的異常處理機制來對請求操作中發生的錯誤進行捕獲和處理。換句話說,我們如果對錯誤不感興趣,也可以使用類似 ​<code>​try?​</code>​​ 和 ​<code>​try!​</code>​ 的

對于 ​<code>​Request​</code>​​,我們需要添加 ​<code>​async​</code>​​ 版本的發送請求的方法。​<code>​dataTask​</code>​​ 的 ​<code>​resumeAsync​</code>​ 方法是在 Foundation 中針對内建異步程式設計所重寫的版本。我們在此等待它的結果,然後将結果解析為 model 後傳回。

我們上面已經說過,可以将 ​<code>​Promise​</code>​​ 看作是對 ​<code>​Result​</code>​​ 的封裝,而這裡我們依然可以類比進行了解,将 ​<code>​async​</code>​​ 看作是對 ​<code>​Promise​</code>​​ 的封裝。對于 ​<code>​sendAsync​</code>​​ 方法,我們完全可以将它了解傳回 ​<code>​Promise​</code>​​,隻不過配合 ​<code>​await​</code>​​,這個 ​<code>​Promise​</code>​​ 将直接以同步的方式被解包為結果。(或者說,​<code>​await​</code>​​ 是這樣一個關鍵字,它可以等待 ​<code>​Promise​</code>​ 完成,并擷取它的結果。)

不僅在網絡請求中可以使用,對于所有的 I/O 操作,Cocoa 應當也會提供一套對應的異步 API。甚至于對于等待使用者操作和輸入,或者等待某個動畫的結束,都是可以使用 ​<code>​async/await​</code>​​ 的潛在場景。如果你對響應式程式設計有所了解的話,不難發現,其實響應式程式設計想要解決的就是異步代碼難以維護的問題,而在使用 ​<code>​async/await​</code>​ 後,部分的異步代碼可以變為以同步形式書寫,這會讓代碼書寫起來簡單很多。

Swift 的 ​<code>​async​</code>​​ 和 ​<code>​await​</code>​​ 很可能将會是基于 ​​Coroutine​​​ 進行實作的。不過也有可能和 C# 類似,編譯器通過将 ​<code>​async​</code>​​ 和 ​<code>​await​</code>​ 的代碼編譯為帶有狀态機的片段,并進行排程。Swift 5 的預計釋出時間會是 2018 年底,是以現在談論這些技術細節可能還為時過早。

講了半天 ​<code>​async​</code>​​ 和 ​<code>​await​</code>​​,它們所要解決的是異步程式設計的問題。而從異步程式設計到并行程式設計,我們還需要一步,那就是将多個異步操作組織起來同時進行。當然,我們可以簡單地同時調用多個 ​<code>​async​</code>​​ 方法來進行并行運算,或者是使用某些像是 GCD 裡 ​<code>​group​</code>​​ 之類的特殊文法來将複數個 ​<code>​async​</code>​ 打包放在一起進行調用。但是不論何種方式,都會面臨一個問題,那就是這套方式使用的是指令式 (imperative) 的文法,而非描述性的 (declarative),這将導緻擴充起來相對困難。

并行程式設計相對複雜,而且與人類天生的思考方式相違背,是以我們希望盡可能讓并行程式設計的模型保持簡單,同時避免直接與線程或者排程這類事務打交道。基于這些考慮,Swift 很可能會參考 ​​Erlang​​​ 和 ​​AKKA​​ 中已經很成功的參與者模型 (actor model) 的方式實作并行程式設計,這樣開發者将可以使用預設的分布式方式和描述性的語言來進行并行任務。

所謂參與者,是一種程式上的抽象概念,它被視為并發運算的基本單元。參與者能做的事情就是接收消息,并且基于收到的消息做某種運算。這和面向對象的想法有相似之處,一個對象也接收消息 (或者說,接受方法調用),并且根據消息 (被調用的方法) 作出響應。它們之間最大的不同在于,參與者之間永遠互相隔離,它們不會共享某塊記憶體。一個參與者中的狀态永遠是私有的,它不能被另一個參與者改變。

和面向對象世界中“萬物皆對象”的思想相同,參與者模式裡,所有的東西也都是參與者。單個的參與者能力十分有限,不過我們可以建立一個參與者的“管理者”,或者叫做 actor system,它在接收到特定消息時可以建立新的參與者,并向它們發送消息。這些新的參與者将實際負責運算或者操作,在接到消息後根據自身的内部狀态進行工作。在 Swift 5 中,可能會用下面的方式來定義一個參與者:

再次注意,這些代碼隻是對 Swift 5 中可能出現的參與者模式的一種猜想。最後的實作肯定會和這有所差別。不過如果 Swift 中要加入參與者,應該會和這裡的表述類似。

這裡的 ​<code>​Message​</code>​ 是我們定義的消息類型。

使用 ​<code>​actor​</code>​ 關鍵字來定義一個參與者模型,它其中包含了内部狀态和異步操作,以及一個隐式的操作隊列。

定義了這個 actor 需要接收的消息和需要作出的響應。

建立了一個 actor system (​<code>​ActorSystem​</code>​​ 這裡沒有給出實作,可能會包含在 Swift 标準庫中)。然後建立了一個 ​<code>​NetworkRequestHandler​</code>​ 參與者,并向它發送一條消息。

這個參與者封裝了一個異步方法以及一個内部狀态,另外,因為該參與者會使用一個自己的 DispatchQueue 以避免和其他線程共享狀态。通過 actor system 進行建立,并在接收到某個消息後執行異步的運算方法,我們就可以很容易地寫出并行處理的代碼,而不必關心它們的内部狀态和排程問題了。現在,你可以通過 ​<code>​ActorSystem​</code>​來建立很多參與者,然後發送不同消息給它們,并進行各自的操作。并行程式設計變得前所未有的簡單。

參與者模式相比于傳統的自己排程有兩個顯著的優點:

首先,因為參與者之間的通訊是消息發送,這意味着并行運算不必被局限在一個程序裡,甚至不必局限在一台裝置裡。隻要保證消息能夠被發送 (比如使用 IPC 或者 DMA),你就完全可以使用分布式的方式,使用多種裝置 (多台電腦,或者多個 GPU) 進行并行操作,這帶來的是無限可能的擴充性。

另外,由于參與者之間可以發送消息,那些操作發生異常的參與者有機會通知 system 自己的狀态,而 actor system 也可以根據這個狀态來重置這些出問題的參與者,或者甚至是無視它們并建立新的參與者繼續任務。這使得整個參與者系統擁有“自愈”的能力,在傳統并行程式設計中想要處理這件事情是非常困難的,而參與者模型的系統得益于此,可以最大限度保障系統的穩定性。

兩年下來,Swift 已經證明了自己是一門非常優秀的 app 語言。即使 Xcode 每日虐我千百遍,但是現在讓我回去寫 Objective-C 的話,我從内心是絕對抗拒的。Swift 的野心不僅于此,從 Swift 的開源和進化方向,我們很容易看出這門語言希望在伺服器端也有所建樹。而内建的異步支援以及參與者模式的并行程式設計,無疑會為 Swift 在伺服器端的運用添加厚重的砝碼。異步模型對寫 app 也會有所幫助,更簡化的控制流程以及隐藏起來的線程切換,會讓我們寫出更加簡明優雅的代碼。

C# 的 async/await 曾經為開發者們帶來一股清流,Elixir 或者說 Erlang 可以說是世界上最優秀的并行程式設計語言,JVM 上的 AKKA 也正在支撐着無數的億級服務。我很好奇當 Swift 遇到這一切的時候,它們之間的化學反應會迸發出怎樣的火花。雖然每天還在 Swift 3 的世界中掙紮,但是我想我的心已經飛躍到 Swift 5 的并行世界中去了。

------------------越是喧嚣的世界,越需要甯靜的思考------------------

合抱之木,生于毫末;九層之台,起于壘土;千裡之行,始于足下。

積土成山,風雨興焉;積水成淵,蛟龍生焉;積善成德,而神明自得,聖心備焉。故不積跬步,無以至千裡;不積小流,無以成江海。骐骥一躍,不能十步;驽馬十駕,功在不舍。锲而舍之,朽木不折;锲而不舍,金石可镂。蚓無爪牙之利,筋骨之強,上食埃土,下飲黃泉,用心一也。蟹六跪而二螯,非蛇鳝之穴無可寄托者,用心躁也。