Swift 並行編程現狀和展望 async/await 和參與者模式 這篇文章不是針對當前版本 Swift 3 的,而是對預計於 2018 年發佈的 Swift 5 的一些特性的猜想。如果兩年後我還記得這篇文章,可能會回來更新一波。在此之前,請當作一篇對現代語言並行編程特性的不太嚴謹科普文來看待。 ...
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 的並行世界中去了。