Swift 并行编程现状和展望

 新闻资讯     |      2019-07-22 09:49

这篇文章不是针对当前版本 swift 3 的,而是对预计于 2018 年发布的 swift 5 的一些特性的猜想。如果两年后我还记得这篇文章,可能会回来更新一波。在此之前,请当作一篇对现代语言并行编程特性的不太严谨科普文来看待。

cpu 速度已经很多年没有大的突破了,硬件行业更多地将重点放在多核心技术上,而与之对应,软件中并行编程的概念也越来越重要。如何利用多核心 cpu,以及拥有密集计算单元的 gpu,来进行快速的处理和计算,是很多开发者十分感兴趣的事情。在今年年初 swift 4 的展望中,swift 项目的负责人 chris lattern 表示可能并不会这么快提供语言层级的并行编程支持,不过最近 chris 又在 ibm 的一次关于中明确提到,有很大可能会在 swift 5 中添加语言级别的并行特性。

这对 swift 生态是一个好消息,也是一个大消息。不过这其实并不是什么新鲜的事情,甚至可以说是一门现代语言发展的必经路径和必备特性。因为 objective-c/swift 现在缺乏这方面的内容,所以很多专注于 ios 的开发者对并行编程会很陌生。我在这篇文章里结合 swift 现状简单介绍了一些这门语言里并行编程可能的使用方式,希望能帮助大家初窥门径。

swift 现在没有语言层面的并行机制,不过我们确实有一些基于库的线程调度的方案,来进行并行操作。

虽然恍如隔世,不过 ___ 确实是从 ios 4 才开始走进我们的视野的。在 ___ 和 block 被加入之前,我们想要新开一个线程需要用到 nsthread 或者 nsoperation,然后使用 delegate 的方式来接收回调。这种书写方式太过古老,也相当麻烦,容易出错。___ 为我们带来了一套很简单的 api,可以让我们在线程中进行调度。在很长一段时间里,这套 api 成为了 ios 中多线程编程的主流方式。swift 继承了这套 api,并且在 swift 3 中将它们重新导入为了更符合 swift 语法习惯的形式。现在我们可以将一个操作很容易地派发到后台进行,首先创建一个后台队列,然后调用 async 并传入需要执行的闭包即可:

let backgroundqueue = dispatchqueue
backgroundqueue.async {
 let result = 1 + 2

在 async 的闭包中,我们还可以继续进行派发,最常见的用法就是开一个后台线程进行耗时操作 ,然后在数据准备完成后,回到主线程更新 ui:

let backgroundqueue = dispatchqueue
backgroundqueue.async {
 let url = url!
 guard let data = try? data else { return }
 let user = user
 dispatchqueue.main.async {
 self.userview.namelabel.text = user.name
 // ...

当然,现在估计已经不会有人再这么做网络请求了。我们可以使用专门的 urlsession 来进行访问。urlsession 和对应的 datatask 会将网络请求派发到后台线程,我们不再需要显式对其指定。不过更新 ui 的工作还是需要回到主线程:

let url = url!
urlsession.shared.datatask {  in
 guard let data = try? data else {
 return
 let user = user
 dispatchqueue.main.async {
 self.userview.namelabel.text = user.name
 // ...
}.resume

基于闭包模型的方式,不论是直接派发还是通过 urlsession 的封装进行操作,都面临一个严重的问题。这个问题最早在 javascript 中____,那就是回调地狱 。

试想一下我们如果有一系列需要依次进行的网络操作:先进行登录,然后使用返回的 token 获取用户信息,接下来通过用户 id 获取好友列表,最后对某个好友点赞。使用传统的闭包方式,这段代码会是这样:

loginrequest.send { token, err in
 if let token = token {
 userprofilerequest.send { user, err in
 if let user = user {
 getfriendlistrequest.send { friends, err in
 if let friends = friends {
 likefriendrequest.send { result, err in
 if let result = result, result {
 print
 self.updateui
 } else {
 print )
 } else {
 print ) 
 } else {
 print )
 } else {
 print )

这已经是使用了尾随闭包特性简化后的代码了,如果使用完整的闭包形式的话,你会看到一大堆 }) 堆叠起来。else路径上几乎不可能确定对应关系,而对于成功的代码路径来说,你也需要很多额外的精力来理解这些代码。一旦这种基于闭包的回调太多,并嵌套起来,阅读它们的时候就好似身陷地狱。

image

不幸的是,在 cocoa 框架中我们似乎对此没太多好办法。不过我们确实有很多方法来解决回调地狱的问题,其中最成功的应该是 promise 或者 future 的方案。

在深入 promise 或 future 之前,我们先来将上面的回调做一些整理。可以看到,所有的请求在回调时都包含了两个输入值,一个是像 token,user 这样我们接下来会使用到的结果,另一个是代表错误的 err。我们可以创建一个泛型类型来代表它们:

enum result t {
 case success
 case failure

重构 send 方法接收的回调类型后,上面的 api 调用就可以变为:

loginrequest.send { result in
 switch result {
 case .success:
 userprofilerequest.send { result in
 switch result {
 case .success:
 // ...
 case .failure:
 print )
 case .failure:
 print )

看起来并没有什么改善,对么?我们只不过使用一堆  的地狱换成了 switch...case 的地狱。但是,我们如果将 request 包装一下,情况就会完全不同。

struct promise t {
 init - void, _ reject: @escaping  - void) - void) {
 //...
 // 存储 fulfill 和 reject。
 // 当 fulfill 被调用时解析为 then;当 reject 被调用时解析为 error。
 // 存储的 then 方法,调用者提供的参数闭包将在 fulfill 时调用
 func then u  - u) - promise u {
 return promise u {
 //...
 // 调用者提供该方法,参数闭包当 reject 时调用
 func `catch` error  - void) {
 //...
extension request {
 var promise: promise response {
 return promise response { fulfill, reject in
 self.send { result in
 switch result {
 case .success: fulfill
 case .failure: reject

我们这里没有给出 promise 的具体实现,而只是给出了概念性的说明。promise 是一个泛型类型,它的初始化方法接受一个以 fulfill 和 reject 作为参数的函数作为参数 。这个类型里还提供了 then 和 catch 方法,then 方法的参数是另一个闭包,在 fulfill 被调用时,我们可以执行这个闭包,并返回新的 promise :而在 reject 被调用时,通过 catch 方法中断这个过程。

在接下来的 request 的扩展中,我们定义了一个返回 promise 的计算属性,它将初始化一个内容类型为 response 的 promise 。我们在 .success 时调用 fulfill,在 .failure 时调用 reject。

现在,上面的回调地狱可以用 then 和 catch 的形式进行展平了:

loginrequest.promise
 .then { token in
 return userprofilerequest.promise
}.then { user in
 return getfriendlistrequest.promise
}.then { friends in
 return likefriendrequest.promise
}.then { _ in
 print
 self.updateui
 // 我们这里还需要在 promise 中添加一个无返回的 then 的重载
 // 篇幅有限,略过
 // ...
}.catch { error in
 print )

promise 本质上就是一个对闭包或者说 result 类型的封装,它将未来可能的结果所对应的闭包先存储起来,然后当确实得到结果 的时候,再执行对应的闭包。通过使用 then,我们可以避免闭包的重叠嵌套,而是使用调用链的方式将异步操作串接起来。future 和 promise 其实是同样思想的不同命名,两者基本指代的是一件事儿。在 swift 中,有一些封装得很好的第三方库,可以让我们以这样的方式来书写代码,promisekit 和 brightfutures 就是其中的佼佼者,它们确实能帮助避免回调地狱的问题,让嵌套的异步代码变得整洁。

image

虽然 promise/future 的方式能解决一部分问题,但是我们看看上面的代码,依然有不少问题。

各个 then 闭包中的值只在自己固定的作用域中有效,这有时候很不方便。比如如果我们的 likefriend 请求需要同时发送当前用户的 token 的话,我们只能在最外层添加临时变量来持有这些结果:

 var mytoken: string = 
 loginrequest.promise
 .then { token in
 mytoken = token
 return userprofilerequest.promise
 } //...
 .then {
 print )
 // ...

swift内建的 throw 的错误处理方式并不能很好地和这里的 result 和 catch { error in ... } 的方式合作。swift throw 是一种同步的错误处理方式,如果想要在异步世界中使用这种的话,会显得格格不入。语法上有不少理解的困难,代码也会迅速变得十分丑陋。

如果从语言层面着手的话,这些问题都是可以被解决的。如果对微软技术栈有所关心的同学应该知道,早在 2012 年 c# 5.0 发布时,就包含了一个让业界惊为天人的特性,那就是 async 和 await 关键字。这两个关键字可以让我们用类似同步的书写方式来写异步代码,这让思维模型变得十分简单。swift 5 中有望引入类似的语法结构,如果我们有 async/await,我们上面的例子将会变成这样的形式:

@ibaction func bunttonpressed {
 // 1
 dosomething
 print
async func dosomething {
 print
 do {
 // 3
 let token = await loginrequest.sendasync
 let user = await userprofilerequest.sendasync
 let friends = await getfriendlistrequest.sendasync
 let result = await likefriendrequest.sendasync
 print
 // 4
 updateui
 } catch ... {
 // 5
 //...
extension request {
 // 6
 async func sendasync - response {
 let datatask = ...
 let data = await datatask.resumeasync
 return response.parse

注意,以上代码是根据现在 swift 语法,对如果存在 async 和 await 时语言的形式的推测。虽然这不代表今后 swift 中异步编程模型就是这样,或者说 async 和 await 就是这样使用,但是应该代表了一个被其他语言验证过的可行方向。

按照注释的编号,进行一些简单的说明:

我们上面已经说过,可以将 promise 看作是对 result 的封装,而这里我们依然可以类比进行理解,将 async 看作是对 promise 的封装。对于 sendasync 方法,我们完全可以将它理解返回 promise,只不过配合 await,这个 promise 将直接以同步的方式被解包为结果。

func sendasync throws - promise response {
 // ...
// await request.sendasync
// doabc
// 等价于
).then {
 // doabc

不仅在网络请求中可以使用,对于所有的 i/o 操作,cocoa 应当也会提供一套对应的异步 api。甚至于对于等待用户操作和输入,或者等待某个动画的结束,都是可以使用 async/await 的潜在场景。如果你对响应式编程有所了解的话,不难发现,其实响应式编程想要解决的就是异步代码难以维护的问题,而在使用 async/await 后,部分的异步代码可以变为以同步形式书写,这会让代码书写起来简单很多。

swift 的 async 和 await 很可能将会是基于 coroutine 进行实现的。不过也有可能和 c# 类似,编译器通过将 async和 await 的代码编译为带有状态机的片段,并进行调度。swift 5 的预计发布时间会是 2018 年底,所以现在谈论这些技术细节可能还为时过早。

讲了半天 async 和 await,它们所要解决的是异步编程的问题。而从异步编程到并行编程,我们还需要一步,那就是将多个异步操作组织起来同时进行。当然,我们可以简单地同时调用多个 async 方法来进行并行运算,或者是使用某些像是 ___ 里 group 之类的特殊语法来将复数个 async 打包放在一起进行调用。但是不论何种方式,都会面临一个问题,那就是这套方式使用的是命令式 的语法,而非描述性的 ,这将导致扩展起来相对困难。

并行编程相对复杂,而且与人类天生的思考方式相违背,所以我们希望尽可能让并行编程的模型保持简单,同时避免直接与线程或者调度这类事务打交道。基于这些考虑,swift 很可能会参考 erlang 和 akka 中已经很成功的参与者模型 的方式实现并行编程,这样开发者将可以使用默认的分布式方式和描述性的语言来进行并行任务。

所谓参与者,是一种程序上的抽象概念,它被视为并发运算的基本单元。参与者能做的事情就是接收消息,并且基于收到的消息做某种运算。这和面向对象的想法有相似之处,一个对象也接收消息 ,并且根据消息 作出响应。它们之间最大的不同在于,参与者之间永远相互隔离,它们不会共享某块内存。一个参与者中的状态永远是私有的,它不能被另一个参与者改变。

和面向对象世界中“万物皆对象”的思想相同,参与者模式里,所有的东西也都是参与者。单个的参与者能力十分有限,不过我们可以创建一个参与者的“管理者”,或者叫做 actor system,它在接收到特定消息时可以创建新的参与者,并向它们发送消息。这些新的参与者将实际负责运算或者操作,在接到消息后根据自身的内部状态进行工作。在 swift 5 中,可能会用下面的方式来定义一个参与者:

// 1
struct message {
 let target: string
actor networkrequesthandler {
 var localstate: userid
 async func processrequest {
 // ...
 // 在这里你可以 await 一个耗时操作
 // 并改变 `localstate` 或者向 system 发消息
 // 3
 message {
 message: processrequest)
let system = actorsystem
let actor = system.actorof networkrequesthandler 
actor.tell)

再次注意,这些代码只是对 swift 5 中可能出现的参与者模式的一种猜想。最后的实现肯定会和这有所区别。不过如果 swift 中要加入参与者,应该会和这里的表述类似。