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 的并行世界中去了。

相关文章
相关标签/搜索