Swift 並行編程現狀和展望 - async/await 和參與者模式

Swift 並行編程現狀和展望 - async/await 和參與者模式

這篇文章不是針對當前版本 Swift 3 的,而是對預計於 2018 年發佈的 Swift 5 的一些特性的猜測。若是兩年後我還記得這篇文章,可能會回來更新一波。在此以前,請看成一篇對現代語言並行編程特性的不太嚴謹科普文來看待。git

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

這對 Swift 生態是一個好消息,也是一個大消息。不過這其實並非什麼新鮮的事情,甚至能夠說是一門現代語言發展的必經路徑和必備特性。由於 Objective-C/Swift 如今缺少這方面的內容,因此不少專一於 iOS 的開發者對並行編程會很陌生。我在這篇文章裏結合 Swift 現狀簡單介紹了一些這門語言裏並行編程可能的使用方式,但願能幫助你們初窺門徑。(雖然我本身也還摸不到門徑在何方…)編程

Swift 現有的並行模型

Swift 如今沒有語言層面的並行機制,不過咱們確實有一些基於庫的線程調度的方案,來進行並行操做。swift

基於閉包的線程調度

雖然恍如隔世,不過 GCD (Grand Central Dispatch) 確實是從 iOS 4 纔開始走進咱們的視野的。在 GCD 和 block 被加入以前,咱們想要新開一個線程須要用到 NSThread 或者 NSOperation,而後使用 delegate 的方式來接收回調。這種書寫方式太過古老,也至關麻煩,容易出錯。GCD 爲咱們帶來了一套很簡單的 API,可讓咱們在線程中進行調度。在很長一段時間裏,這套 API 成爲了 iOS 中多線程編程的主流方式。Swift 繼承了這套 API,而且在 Swift 3 中將它們從新導入爲了更符合 Swift 語法習慣的形式。如今咱們能夠將一個操做很容易地派發到後臺進行,首先建立一個後臺隊列,而後調用 async 並傳入須要執行的閉包便可:api

let backgroundQueue = DispatchQueue(label: "com.onevcat.concurrency.backgroundQueue")
backgroundQueue.async {
    let result = 1 + 2
}

在 async 的閉包中,咱們還能夠繼續進行派發,最多見的用法就是開一個後臺線程進行耗時操做 (從網絡獲取數據,或者 I/O 等),而後在數據準備完成後,回到主線程更新 UI:promise

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路徑上幾乎不可能肯定對應關係,而對於成功的代碼路徑來講,你也須要不少額外的精力來理解這些代碼。一旦這種基於閉包的回調太多,並嵌套起來,閱讀它們的時候就好似身陷地獄。閉包

image

不幸的是,在 Cocoa 框架中咱們彷佛對此沒太多好辦法。不過咱們確實有不少方法來解決回調地獄的問題,其中最成功的應該是 Promise 或者 Future 的方案。

Promise/Future

在深刻 Promise 或 Future 以前,咱們先來將上面的回調作一些整理。能夠看到,全部的請求在回調時都包含了兩個輸入值,一個是像 tokenuser 這樣咱們接下來會使用到的結果,另外一個是表明錯誤的 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 就是其中的佼佼者,它們確實能幫助避免回調地獄的問題,讓嵌套的異步代碼變得整潔。

image

async/await,「串行」模式的異步編程

雖然 Promise/Future 的方式能解決一部分問題,可是咱們看看上面的代碼,依然有很多問題。

  1. 咱們用了不少並不直觀的操做,對於每一個 request,咱們都生成了額外的 Promise,並用 then 串聯。這些其實都是模板代碼,應該能夠被更好地解決。
  2. 各個 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)")
         // ...
     }
  3. 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 就是這樣使用,可是應該表明了一個被其餘語言驗證過的可行方向。

按照註釋的編號,進行一些簡單的說明:

  1. 這就是咱們一般的 @IBAction,點擊後執行 doSomething
  2. doSomething 被 async 關鍵字修飾,表示這是一個異步方法。async 關鍵字所作的事情只有一件,那就是容許在這個方法內使用 await 關鍵字來等待一個長時間操做完成。在這個方法裏的語句將被以同步方式執行,直到遇到第一個 await。控制檯將會打印 「Doing something…「。
  3. 遇到的第一個 await。此時這個 doSomething 方法將進入等待狀態,該方法將會「返回」,也即離開棧域。接下來 bunttonPressed 中 doSomething 調用以後的語句將被執行,控制檯打印 「Button Pressed」。
  4. tokenuserfriends 和 result 將被依次 await 執行,直到得到最終結果,並進行 updateUI
  5. 理論上 await 關鍵字在語義上應該包含 throws,因此咱們須要將它們包裹在 do...catch 中,並且可使用 Swift 內建的異常處理機制來對請求操做中發生的錯誤進行捕獲和處理。換句話說,咱們若是對錯誤不感興趣,也可使用相似 try? 和 try! 的
  6. 對於 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 中要加入參與者,應該會和這裏的表述相似。

  1. 這裏的 Message 是咱們定義的消息類型。
  2. 使用 actor 關鍵字來定義一個參與者模型,它其中包含了內部狀態和異步操做,以及一個隱式的操做隊列。
  3. 定義了這個 actor 須要接收的消息和須要做出的響應。
  4. 建立了一個 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 的並行世界中去了。

相關文章
相關標籤/搜索