Swift 并行編程現狀和展望 - async/await 和參與者模式
這篇文章不是針對當前版本 Swift 3 的,而是對預計于 2018 年發布的 Swift 5 的一些特性的猜想。如果兩年后我還記得這篇文章,可能會回來更新一波。在此之前,請當作一篇對現代語言并行編程特性的不太嚴謹科普文來看待。
CPU 速度已經很多年沒有大的突破了,硬件行業更多地將重點放在多核心技術上,而與之對應,軟件中并行編程的概念也越來越重要。如何利用多核心 CPU,以及擁有密集計算單元的 GPU,來進行快速的處理和計算,是很多開發者十分感興趣的事情。在今年年初 Swift 4 的展望中,Swift 項目的負責人 Chris Lattern 表示可能并不會這么快提供語言層級的并行編程支持,不過最近 Chris 又在 IBM 的一次關于 編譯器的分享 中明確提到,有很大可能會在 Swift 5 中添加語言級別的并行特性。
這對 Swift 生態是一個好消息,也是一個大消息。不過這其實并不是什么新鮮的事情,甚至可以說是一門現代語言發展的必經路徑和必備特性。因為 Objective-C/Swift 現在缺乏這方面的內容,所以很多專注于 iOS 的開發者對并行編程會很陌生。我在這篇文章里結合 Swift 現狀簡單介紹了一些這門語言里并行編程可能的使用方式,希望能幫助大家初窺門徑。(雖然我自己也還摸不到門徑在何方...)
Swift 現有的并行模型
Swift 現在沒有語言層面的并行機制,不過我們確實有一些基于庫的線程調度的方案,來進行并行操作。
基于閉包的線程調度
雖然恍如隔世,不過 GCD (Grand Central Dispatch) 確實是從 iOS 4 才開始走進我們的視野的。在 GCD 和 block 被加入之前,我們想要新開一個線程需要用到 NSThread 或者 NSOperation ,然后使用 delegate 的方式來接收回調。這種書寫方式太過古老,也相當麻煩,容易出錯。GCD 為我們帶來了一套很簡單的 API,可以讓我們在線程中進行調度。在很常一段時間里,這套 API 成為了 iOS 中多線程編程的主流方式。Swift 繼承了這套 API,并且在 Swift 3 中將它們重新導入為了更符合 Swift 語法習慣的形式。現在我們可以將一個操作很容易地派發到后臺進行,首先創建一個后臺隊列,然后調用 async 并傳入需要執行的閉包即可:
let backgroundQueue = DispatchQueue(label: "com.onevcat.concurrency.backgroundQueue")
backgroundQueue.async {
let result = 1 + 2
}
在 async 的閉包中,我們還可以繼續進行派發,最常見的用法就是開一個后臺線程進行耗時操作 (從網絡獲取數據,或者 I/O 等),然后在數據準備完成后,回到主線程更新 UI:
let backgroundQueue = DispatchQueue(label: "com.onevcat.concurrency.backgroundQueue")
backgroundQueue.async {
let url = URL(string: "https://api.onevcat.com/users/onevcat")!
guard let data = try? Data(contentsOf: url) else { return }
let user = User(data: data)
DispatchQueue.main.async {
self.userView.nameLabel.text = user.name
// ...
}
}
當然,現在估計已經不會有人再這么做網絡請求了。我們可以使用專門的 URLSession 來進行訪問。 URLSession 和對應的 dataTask 會將網絡請求派發到后臺線程,我們不再需要顯式對其指定。不過更新 UI 的工作還是需要回到主線程:
let url = URL(string: "https://api.onevcat.com/users/onevcat")!
URLSession.shared.dataTask(with: url) { (data, res, err) in
guard let data = try? Data(contentsOf: url) else {
return
}
let user = User(data: data)
DispatchQueue.main.async {
self.userView.nameLabel.text = user.name
// ...
}
}.resume()
回調地獄
基于閉包模型的方式,不論是直接派發還是通過 URLSession 的封裝進行操作,都面臨一個嚴重的問題。這個問題最早在 JavaScript 中臭名昭著,那就是回調地獄 (callback hell)。
試想一下我們如果有一系列需要依次進行的網絡操作:先進行登錄,然后使用返回的 token 獲取用戶信息,接下來通過用戶 ID 獲取好友列表,最后對某個好友點贊。使用傳統的閉包方式,這段代碼會是這樣:
LoginRequest(userName: "onevcat", password: "123").send() { token, err in
if let token = token {
UserProfileRequest(token: token).send() { user, err in
if let user = user {
GetFriendListRequest(user: user).send() { friends, err in
if let friends = friends {
LikeFriendRequest(target: friends.first).send() { result, err in
if let result = result, result {
print("Success")
self.updateUI()
}
} else {
print("Error: \(err)")
}
} else {
print("Error: \(err)")
}
}
} else {
print("Error: \(err)")
}
}
} else {
print("Error: \(err)")
}
}
這已經是使用了尾隨閉包特性簡化后的代碼了,如果使用完整的閉包形式的話,你會看到一大堆 }) 堆疊起來。 else 路徑上幾乎不可能確定對應關系,而對于成功的代碼路徑來說,你也需要很多額外的精力來理解這些代碼。一旦這種基于閉包的回調太多,并嵌套起來,閱讀它們的時候就好似身陷地獄。
不幸的是,在 Cocoa 框架中我們似乎對此沒太多好辦法。不過我們確實有很多方法來解決回調地獄的問題,其中最成功的應該是 Promise 或者 Future 的方案。
Promise/Future
在深入 Promise 或 Future 之前,我們先來將上面的回調做一些整理。可以看到,所有的請求在回調時都包含了兩個輸入值,一個是像 token , user 這樣我們接下來會使用到的結果,另一個是代表錯誤的 err 。我們可以創建一個泛型類型來代表它們:
enum Result<T> {
case success(T)
case failure(Error)
}
重構 send 方法接收的回調類型后,上面的 API 調用就可以變為:
LoginRequest(userName: "onevcat", password: "123").send() { result in
switch result {
case .success(let token):
UserProfileRequest(token: token).send() { result in
switch result {
case .success(let user):
// ...
case .failure(let error):
print("Error: \(error)")
}
}
case .failure(let error):
print("Error: \(error)")
}
}
看起來并沒有什么改善,對么?我們只不過使用一堆 ({}) 的地獄換成了 switch...case 的地獄。但是,我們如果將 request 包裝一下,情況就會完全不同。
struct Promise<T> {
init(resolvers: (_ fulfill: @escaping (T) -> Void, _ reject: @escaping (Error) -> Void) -> Void) {
//...
// 存儲 fulfill 和 reject。
// 當 fulfill 被調用時解析為 then;當 reject 被調用時解析為 error。
}
// 存儲的 then 方法,調用者提供的參數閉包將在 fulfill 時調用
func then<U>(_ body: (T) -> U) -> Promise<U> {
return Promise<U>{
//...
}
}
// 調用者提供該方法,參數閉包當 reject 時調用
func `catch`<Error>(_ body: (Error) -> Void) {
//...
}
}
extension Request {
var promise: Promise<Response> {
return Promise<Response> { fulfill, reject in
self.send() { result in
switch result {
case .success(let r): fulfill(r)
case .failure(let e): reject(e)
}
}
}
}
}
我們這里沒有給出 Promise 的具體實現,而只是給出了概念性的說明。 Promise 是一個泛型類型,它的初始化方法接受一個以 fulfill 和 reject 作為參數的函數作為參數 (一開始這可能有點拗口,你可以結合代碼再讀一次)。這個類型里還提供了 then 和 catch 方法, then 方法的參數是另一個閉包,在 fulfill 被調用時,我們可以執行這個閉包,并返回新的 Promise (之后會看到具體的使用例子):而在 reject 被調用時,通過 catch 方法中斷這個過程。
在接下來的 Request 的擴展中,我們定義了一個返回 Promise 的計算屬性,它將初始化一個內容類型為 Response 的 Promise (這里的 Response 是定義在 Request 協議中的代表該請求對應的響應的類型,想了解更多相關的內容,可以看看我之前的一篇使用面向協議編程的文章)。我們在 .success 時調用 fulfill ,在 .failure 時調用 reject 。
現在,上面的回調地獄可以用 then 和 catch 的形式進行展平了:
LoginRequest(userName: "onevcat", password: "123").promise
.then { token in
return UserProfileRequest(token: token).promise
}.then { user in
return GetFriendListRequest(user: user).promise
}.then { friends in
return LikeFriendRequest(target: friends.first).promise
}.then { _ in
print("Succeed!")
self.updateUI()
// 我們這里還需要在 Promise 中添加一個無返回的 then 的重載
// 篇幅有限,略過
// ...
}.catch { error in
print("Error: \(error)")
}
Promise 本質上就是一個對閉包或者說 Result 類型的封裝,它將未來可能的結果所對應的閉包先存儲起來,然后當確實得到結果 (比如網絡請求返回) 的時候,再執行對應的閉包。通過使用 then ,我們可以避免閉包的重疊嵌套,而是使用調用鏈的方式將異步操作串接起來。 Future 和 Promise 其實是同樣思想的不同命名,兩者基本指代的是一件事兒。在 Swift 中,有一些封裝得很好的第三方庫,可以讓我們以這樣的方式來書寫代碼, PromiseKit 和 BrightFutures 就是其中的佼佼者,它們確實能幫助避免回調地獄的問題,讓嵌套的異步代碼變得整潔。
async/await,“串行”模式的異步編程
雖然 Promise/Future 的方式能解決一部分問題,但是我們看看上面的代碼,依然有不少問題。
- 我們用了很多并不直觀的操作,對于每個 request,我們都生成了額外的 Promise ,并用 then 串聯。這些其實都是模板代碼,應該可以被更好地解決。
-
各個 then 閉包中的值只在自己固定的作用域中有效,這有時候很不方便。比如如果我們的 LikeFriend 請求需要同時發送當前用戶的 token 的話,我們只能在最外層添加臨時變量來持有這些結果:
var myToken: String = "" LoginRequest(userName: "onevcat", password: "123").promise .then { token in myToken = token return UserProfileRequest(token: token).promise } //... .then { print("Token is \(myToken)") // ... }
-
Swift 內建的 throw 的錯誤處理方式并不能很好地和這里的 Result 和 catch { error in ... } 的方式合作。Swift throw 是一種同步的錯誤處理方式,如果想要在異步世界中使用這種的話,會顯得格格不入。語法上有不少理解的困難,代碼也會迅速變得十分丑陋。
如果從語言層面著手的話,這些問題都是可以被解決的。如果對微軟技術棧有所關心的同學應該知道,早在 2012 年 C# 5.0 發布時,就包含了一個讓業界驚為天人的特性,那就是 async 和 await 關鍵字。這兩個關鍵字可以讓我們用類似同步的書寫方式來寫異步代碼,這讓思維模型變得十分簡單。Swift 5 中有望引入類似的語法結構,如果我們有 async/await,我們上面的例子將會變成這樣的形式:
@IBAction func bunttonPressed(_ sender: Any?) {
// 1
doSomething()
print("Button Pressed")
}
// 2
async func doSomething() {
print("Doing something...")
do {
// 3
let token = await LoginRequest(userName: "onevcat", password: "123").sendAsync()
let user = await UserProfileRequest(token: token).sendAsync()
let friends = await GetFriendListRequest(user: user).sendAsync()
let result = await LikeFriendRequest(target: friends.first).sendAsync()
print("Finished")
// 4
updateUI()
} catch ... {
// 5
//...
}
}
extension Request {
// 6
async func sendAsync() -> Response {
let dataTask = ...
let data = await dataTask.resumeAsync()
return Response.parse(data: data)
}
}
注意,以上代碼是根據現在 Swift 語法,對如果存在 async 和 await 時語言的形式的推測。雖然這不代表今后 Swift 中異步編程模型就是這樣,或者說 async 和 await 就是這樣使用,但是應該代表了一個被其他語言驗證過的可行方向。
按照注釋的編號,進行一些簡單的說明:
- 這就是我們通常的 @IBAction ,點擊后執行 doSomething 。
- doSomething 被 async 關鍵字修飾,表示這是一個異步方法。 async 關鍵字所做的事情只有一件,那就是允許在這個方法內使用 await 關鍵字來等待一個長時間操作完成。在這個方法里的語句將被以同步方式執行,直到遇到第一個 await 。控制臺將會打印 "Doing something..."。
- 遇到的第一個 await。此時這個 doSomething 方法將進入等待狀態,該方法將會“返回”,也即離開棧域。接下來 bunttonPressed 中 doSomething 調用之后的語句將被執行,控制臺打印 "Button Pressed"。
- token , user , friends 和 result 將被依次 await 執行,直到獲得最終結果,并進行 updateUI 。
- 理論上 await 關鍵字在語義上應該包含 throws ,所以我們需要將它們包裹在 do...catch 中,而且可以使用 Swift 內建的異常處理機制來對請求操作中發生的錯誤進行捕獲和處理。換句話說,我們如果對錯誤不感興趣,也可以使用類似 try? 和 try! 的
- 對于 Request ,我們需要添加 async 版本的發送請求的方法。 dataTask 的 resumeAsync 方法是在 Foundation 中針對內建異步編程所重寫的版本。我們在此等待它的結果,然后將結果解析為 model 后返回。
我們上面已經說過,可以將 Promise 看作是對 Result 的封裝,而這里我們依然可以類比進行理解,將 async 看作是對 Promise 的封裝。對于 sendAsync 方法,我們完全可以將它理解返回 Promise ,只不過配合 await ,這個 Promise 將直接以同步的方式被解包為結果。(或者說, await 是這樣一個關鍵字,它可以等待 Promise 完成,并獲取它的結果。)
func sendAsync() throws -> Promise<Response> {
// ...
}
// await request.sendAsync()
// doABC()
// 等價于
(try request.sendAsync()).then {
// doABC()
}
不僅在網絡請求中可以使用,對于所有的 I/O 操作,Cocoa 應當也會提供一套對應的異步 API。甚至于對于等待用戶操作和輸入,或者等待某個動畫的結束,都是可以使用 async/await 的潛在場景。如果你對響應式編程有所了解的話,不難發現,其實響應式編程想要解決的就是異步代碼難以維護的問題,而在使用 async/await 后,部分的異步代碼可以變為以同步形式書寫,這會讓代碼書寫起來簡單很多。
Swift 的 async 和 await 很可能將會是基于 Coroutine 進行實現的。不過也有可能和 C# 類似,編譯器通過將 async 和 await 的代碼編譯為帶有狀態機的片段,并進行調度。Swift 5 的預計發布時間會是 2018 年底,所以現在談論這些技術細節可能還為時過早。
參與者 (actor) 模型
講了半天 async 和 await ,它們所要解決的是異步編程的問題。而從異步編程到并行編程,我們還需要一步,那就是將多個異步操作組織起來同時進行。當然,我們可以簡單地同時調用多個 async 方法來進行并行運算,或者是使用某些像是 GCD 里 group 之類的特殊語法來將復數個 async 打包放在一起進行調用。但是不論何種方式,都會面臨一個問題,那就是這套方式使用的是命令式 (imperative) 的語法,而非描述性的 (declarative),這將導致擴展起來相對困難。
并行編程相對復雜,而且與人類天生的思考方式相違背,所以我們希望盡可能讓并行編程的模型保持簡單,同時避免直接與線程或者調度這類事務打交道。基于這些考慮,Swift 很可能會參考 Erlang 和 AKKA 中已經很成功的參與者模型 (actor model) 的方式實現并行編程,這樣開發者將可以使用默認的分布式方式和描述性的語言來進行并行任務。
所謂參與者,是一種程序上的抽象概念,它被視為并發運算的基本單元。參與者能做的事情就是接收消息,并且基于收到的消息做某種運算。這和面向對象的想法有相似之處,一個對象也接收消息 (或者說,接受方法調用),并且根據消息 (被調用的方法) 作出響應。它們之間最大的不同在于,參與者之間永遠相互隔離,它們不會共享某塊內存。一個參與者中的狀態永遠是私有的,它不能被另一個參與者改變。
和面向對象世界中“萬物皆對象”的思想相同,參與者模式里,所有的東西也都是參與者。單個的參與者能力十分有限,不過我們可以創建一個參與者的“管理者”,或者叫做 actor system,它在接收到特定消息時可以創建新的參與者,并向它們發送消息。這些新的參與者將實際負責運算或者操作,在接到消息后根據自身的內部狀態進行工作。在 Swift 5 中,可能會用下面的方式來定義一個參與者:
// 1
struct Message {
let target: String
}
// 2
actor NetworkRequestHandler {
var localState: UserID
async func processRequest(connection: Connection) {
// ...
// 在這里你可以 await 一個耗時操作
// 并改變 `localState` 或者向 system 發消息
}
// 3
message {
Message(let m): processRequest(connection: Connection(m.target))
}
}
// 4
let system = ActorSystem(identifier: "MySystem")
let actor = system.actorOf<NetworkRequestHandler>()
actor.tell(Message(target: "https://onevcat.com"))
再次注意,這些代碼只是對 Swift 5 中可能出現的參與者模式的一種猜想。最后的實現肯定會和這有所區別。不過如果 Swift 中要加入參與者,應該會和這里的表述類似。
- 這里的 Message 是我們定義的消息類型。
- 使用 actor 關鍵字來定義一個參與者模型,它其中包含了內部狀態和異步操作,以及一個隱式的操作隊列。
- 定義了這個 actor 需要接收的消息和需要作出的響應。
- 創建了一個 actor system ( ActorSystem 這里沒有給出實現,可能會包含在 Swift 標準庫中)。然后創建了一個 NetworkRequestHandler 參與者,并向它發送一條消息。
這個參與者封裝了一個異步方法以及一個內部狀態,另外,因為該參與者會使用一個自己的 DispatchQueue 以避免和其他線程共享狀態。通過 actor system 進行創建,并在接收到某個消息后執行異步的運算方法,我們就可以很容易地寫出并行處理的代碼,而不必關心它們的內部狀態和調度問題了。現在,你可以通過 ActorSystem 來創建很多參與者,然后發送不同消息給它們,并進行各自的操作。并行編程變得前所未有的簡單。
參與者模式相比于傳統的自己調度有兩個顯著的優點:
首先,因為參與者之間的通訊是消息發送,這意味著并行運算不必被局限在一個進程里,甚至不必局限在一臺設備里。只要保證消息能夠被發送 (比如使用 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 的并行世界中去了。
來自:http://onevcat.com/2016/12/concurrency/