Swift 中的并发之 async/await —— WWDC 2021

| Swift , iOS , WWDC

 

内容概览

  • 前言
  • 为什么需要 async/await ?
  • 简洁、高效而且安全的 async/await
  • async/await 做了什么?
  • 新的并发特性 async/await 该怎么上手?
  • 为什么 Swift 5 中会决定采用 async/await 并发机制?
  • 总结

 

前言

 

这一次 WWDC 2021,让我们很多苹果开发者眼前一亮的是 Swift 中的并发特性:async/await,虽然这个概念并不新。

您可能会有以下疑问:

  • 为什么 Swift 5 会被加入并发机制,我们不是有 GCD 这些任务调度框架了吗?
  • async/await 做了什么?
  • 新的并发特性 async/await 该怎么上手?
  • 为什么 Swift 5 中会决定采用 async/await 并发机制?

请别急,现在来和 Ficow 一起揭晓答案吧~

 

为什么需要 async / await ?

 

我们常见的同步和异步调用的模式如下:

同步执行:线程按代码的先后顺序依次执行,前面的代码未执行结束时,线程不会执行后续的代码;

异步执行(回调方式):线程执行到异步代码的时候,会同时继续执行异步代码和后续的同步代码,当异步代码完成时线程就会调用回调函数中的代码;

 

这是一个常见的异步执行的示例,列表中的图标通常通过异步的方式获取:

现在让我们来拆解这个示例,示例中的子任务一个接一个地进行:

其中有一些比较耗时的操作,它们应该异步执行:

其中,获取缩略图的方法的实现部分如下所示:

在回调函数中,多级的条件判定逻辑非常容易导致错误。比如,红色箭头指向的地方就未对失败情况进行正确的处理,而且是 两处 都漏掉了!如果这种情况发生了,列表上的图标就不会被加载出来,而是一直显示一个加载指示器。

 

现在我们再回头一看,为了异步加载缩略图,居然就需要 写这么多代码,而且代码这么容易就出错了,不开心啊!

我想,您现在应该也已经发现我们熟悉的异步回调模式存在的 明显缺陷 了。那么,如何改进呢?

 

使用标准库中的 Result 类型也许是一种改进方法。然而,问题依然存在,而且代码还变得更长了:

也许还有人会说,把 if-else 的最后一个 else 稍加调整就可以减少 guard-else 的缩进层级,然后就可以缓解这种情况了。

这也是一种解决办法,然而它不能从根本上解决问题,而且这个方法还要开发者牢记于心才行。

 

那么,有没有什么强制手段来改进这种问题呢?
比如,让编译器帮忙检查异步代码 的逻辑是否存在问题?
而且,我们想让异步代码变得 更简单、安全,有没有可能呢?

 

简洁、高效而且安全的 async/await

 

新技术的出现一定是为了更好地解决已有的问题,而且旧技术往往无法很好地处理这些问题。

为了解决常见的异步处理问题,Swift 中的 async/await 应运而生。它可以有效地精简代码、消灭回调地狱,而且更充分地利用 CPU 资源!

 

Swift 协程

Swift 中的 async/await 本质上来说,就是协程

对于协程和线程的区别,维基百科的解释大致如下:

  • 协程 很像线程;
  • 协程是协同多任务,而线程是典型的抢先式多任务,所以 协程支持并发,但是不支持并行
  • 协程相对于线程具有这些优势:切换协程不涉及系统调用或者任何阻塞调用不需要使用同步原语 (如:互斥、信号量等)来保护临界区;

请注意:并发是同时管理多个任务,并行是同时执行多个任务;

 

实际上,很多编程语言都已经支持协程,比如:Go Coroutines, Kotlin Coroutines

甚至 ObjC 也已经支持协程,这是阿里巴巴开源的协程库:alibaba/coobjc,而且此库提供的协程也基于 async/await 模型。

此外,该开源协程库的文档开篇就提到了 iOS 异步编程的问题,而且总结得非常全面:

可能是 Swift 进化太慢了,所以也有人急不可耐。SwiftCoroutine 就是一个很好的例子:

如果仔细观察这个库的用法,您就会发现它好像并不易用,或者说接口的设计并不简洁、自然。

为什么这么说呢?让我们一起来看一下 Swift 语言原生的 async/await 吧~

 

优雅的 async/await

同样是之前的获取缩略图的示例,如果使用 Swift 原生的 async/await 重写之后,变成了这样:

请注意: 同步执行 thumbnailURLRequest(for: id) 时,当前线程会被阻塞。执行 try await URLSession.shared.data(for:request) 时,当前协程会被挂起(suspend),此时不会被阻塞,所以线程还可以去执行其他任务。

对比之前的异步回调函数版本,使用 async/await 的版本具有以下优势:

  • 简洁,仅仅 6 行代码!时间就是金钱,多敲几下键盘也是浪费生命;
  • 通过 throw 来进行错误处理,不会再漏掉任何错误情况;
  • (被标记了 awiat)异步方法可以挂起当前协程,不阻塞当前的线程并把线程资源让出来,直到异步调用获得结果;
  • try await 可以捕获异步方法中抛出的错误;

 

不仅方法可以异步执行,只读属性 也可以!

如果此属性会抛出错误,您还可以这样定义:

    get async throws {
        return try await self.byPreparingThumbnail(ofSize: .init(width: 40, height: 40))
    }

甚至,连 for 循环也可以异步执行了呢~

如果您对此感兴趣,可以参考 Meet AsyncSequence
如果您需要处理大量的异步操作,可以参考 Explore structured concurrency in Swift

 

async/await 做了什么?

 

正常的方法调用会一直占用线程资源,直到方法调用结束(return),然后线程的控制权才会返回到该方法的调用方。也就是说,正常的方法只能通过完成调用才可以放弃对线程的控制权。此过程如下图所示:

异步方法调用可以选择挂起并把线程的控制权交给系统(不是交给此方法的调用方),让系统来决定如何使用该线程(这就是所谓的协同式多任务)。之后,在某个适当的时机,系统又会把线程(可能不是之前的线程)的控制权交回给此异步方法。当然,这个方法然后可以选择再次挂起。此过程如图所示:

这里还有一些注意事项:

  • 被标记了 async 关键字的方法支持异步执行(可以被挂起);
  • 被标记了 await 关键字的方法表明异步方法 可能 被挂起,但是它不一定会被挂起。
  • 方法被挂起后,当前线程会根据系统的调度去执行其他任务,所以程序的执行步骤相比于同步调用会有很大的区别。
  • 方法被挂起前和恢复执行后所在的线程可能不是同一个,这时候需要注意 数据竞争(Data Race)问题。

对于数据竞争问题,您可以参考 Protect mutable state with Swift actors 中提供的解决方法。

 

新的并发特性 async/await 该怎么上手?

 

重写基于 XCTest 的异步测试

现在,异步测试代码可以用 async/await 来重写:

用 async/await 重写后,测试方法变得如此简洁(就 1 行):

用 async 重写异步回调闭包

异步回调闭包是很常见的代码,比如:

可以使用 async 来重写:

更多具体的用法,请参考:

用新的支持 async 的 SDK 接口方法

形如这些支持异步回调闭包的SDK接口:

现在都已经拥有了支持 async 的版本:

SDK 常见的代理方法,比如:

现在也可以用 async/await 来重写:

关于SDK提供的支持 async 的新接口方法,这是一些相关的 WWDC 2021 视频:

使用 Continuation 连接回调闭包和 async 异步代码

首先,这是一段使用了回调闭包的代码,它从 CoreData 中读取数据,然后执行回调:

如果想要对这个方法进行封装,我们会遇到一点阻碍。该如何连接回调方法和 async 呢?

其实,这是一个常见的异步执行模式,和前文中的示例非常相似:

所以,很明显,这里缺少了一个连接的步骤。

Swift 为此引入了 Continuation 这个概念。这里,我们可以借助 CheckedContinuation 来完成这个连接:

使用 CheckedContinuation 需要注意:

  • 继续执行的路径只能有一个可以调用 Continuation 的 resume 方法,而且只能调用 1 次;
  • 如果 忘记调用 Continuation 的 resume 方法,Swift 运行时会报错;

此外,常见的代理方法也可以用 Continuation 连接以实现异步处理:

CheckedContinuation 连接后的效果:

让我们再来回顾一下可以应用 Continuation 的常见异步执行模式:

如果想了解更多 Swift 并发的细节,请参考: Swift concurrency: Behind the scenes

 

上手建议

  • 逐步地用 async/await 来重写旧的异步代码;
  • 在提供支持异步回调闭包的接口时,也提供支持 async 的版本;
  • 使用 Xcode 中的 async 重构操作;

 

为什么 Swift 5 中会决定采用 async/await 并发机制?

 

其实,Swift 一直在进化中,而且这个过程还将继续下去。

《Swift 进阶》的错误处理这一章的结尾中,作者对 Swift 中的错误处理进行了展望:

如今,async/await 风格的并发模型支持异步函数的 throws 错误处理 在 Swift 中皆已实现。

 

实际上,就单从并发这一个方面来看,Swift 都还有很长的路要走。这是 Swift 之父 Chris Lattner 对于 Swift 并发的构思(Swift Concurrency Manifesto),从这里我们也能看得出,Swift 还会继续成长,直到它比任何 competitor 都要伟大。

这是 Swift Concurrency Manifesto 的内容概览,可以看出 Swift 已经完成了 Part 1 和 Part 2:

其中 Learning from other concurrency designs 分析和探讨了诸多语言中的并发设计,可以帮助您拓展眼界,值得一看~

 

总结

 

从最初的多进程任务调度系统,到后来的多线程任务调度系统,再到现在的协程调度系统,可被利用的资源一再被细分。

如今已是 多核计算机时代,如何更高效地利用 CPU 的多核资源是现代编程语言都要面对的一个长期问题。

Swift 作为一门年轻的现代编程语言,有很多优秀的先例可供学习,所以现在成长迅速。

然而,之后的路要怎样走就是一个未知数了。所以,在可以预见的未来,Swift 在并发模式上应该不会有太大的变化。

不管怎样,我们都应该真诚地感谢 Swift 团队和开源社区对 Swift 的巨大贡献。❤️ 如今,我们可以以一种更加简洁、高效且安全的方式来实现异步任务。

 

参考内容:
Meet async/await in Swift
Swift Concurrency Manifesto
Go Coroutines
Kotlin Coroutines
alibaba/coobjc
SwiftCoroutine
Meet AsyncSequence
Explore structured concurrency in Swift
Swift concurrency: Behind the scenes

 

觉得不错?点个赞呗~

本文链接:Swift 中的并发之 async/await —— WWDC 2021

转载声明:本站文章如无特别说明,皆为原创。转载请注明:Ficow Shen's Blog

评论区(期待你的留言)