Swift 并发新体验

引言

对于诞生于 2014 年的 Swift 而言,它已不再年轻。至今我还记得初次体验 Swift 时的喜悦之情,比起冗长的 OC 而言,它更加现代、简洁、优雅。但 Swift 的前期发展是野蛮而动荡的,每次发布新版本时都会导致旧项目出现大量的报错和告警,项目迁移工作令开发者苦不堪言。不得不说,Swift 诞生之初就敢于在项目中实践并运用的人,是真的猛士。我是从 Swift 4 才开始将项目逐渐从 OC 向 Swift 迁移的,到 2019 年 Swift 5 实现了 ABI 稳定时,才全面迁移至纯 Swift 开发。

ABI 的稳定象征着 Swift 的成熟,然而在并发编程方面,Swift 却落后了一截。Chris Lattner 早在2017年发表的 《Swift 并发宣言》 中就描绘了令人兴奋的前景。2021 年 Swift 5.5 的发布终于将 Concurrency 加入了标准库,从此,Swift 并发编程变得更为简单、高效和安全。

在此之前,我们通常使用闭包来处理异步事件的回调,如下是一个下载网络图片的示例:

func fetchImage(from: String, completion: @escaping (Result<UIImage?, Error>) -> Void) {
  URLSession.shared.dataTask(with: .init(string: from)!) { data, resp, error in
    if let error = error {
      completion(.failure(error))
    } else {
      DispatchQueue.main.async {
        completion(.success(.init(data: data!)))
      }
    }
  }.resume()
}
复制代码

代码并不复杂,不过这只是针对下载单一图片的场景。我们将需求设计的再复杂一点点:先下载前两张图片(无先后顺序)并展示,然后再下载第三张图片并展示,当三张图片都下载完成后,再展示在 UI 界面。当然,实际开发中一般是先下载的图片先展示,这里的非常规设计只作举例而已。

完整的实现代码变成如下:

import UIKit

class ViewController: UIViewController {
  
  let sv = UIScrollView(frame: UIScreen.main.bounds)
  let imageViews = [UIImageView(), UIImageView(), UIImageView()]
  let from = [
    "https://images.pexels.com/photos/10646758/pexels-photo-10646758.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=500",
    "https://images.pexels.com/photos/9391321/pexels-photo-9391321.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=500",
    "https://images.pexels.com/photos/9801136/pexels-photo-9801136.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=500"
  ]
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    sv.backgroundColor = .white
    view.addSubview(sv)
    sv.contentSize = .init(width: 0, height: UIScreen.main.bounds.height + 100)
    
    imageViews.enumerated().forEach { i, v in
      v.backgroundColor = .lightGray
      v.contentMode = .scaleAspectFill
      v.clipsToBounds = true
      v.frame = .init(x: 0, y: CGFloat(i) * 220, width: UIScreen.main.bounds.width, height: 200)
      sv.addSubview(v)
    }
    
    let group = DispatchGroup()
    let queue = DispatchQueue(label: "fetchImage", qos: .userInitiated, attributes: .concurrent)
    
    let itemClosure: (Int, DispatchWorkItemFlags, @escaping () -> ()) -> DispatchWorkItem = { idx, flags, completion in
      return DispatchWorkItem(flags: flags) {
        self.fetchImage(from: self.from[idx]) { result in
          print(idx)
          switch result {
          case let .success(image):
            self.imageViews[idx].image = image
          case let .failure(error):
            print(error)
          }
          completion()
        }
      }
    }
    
    from.enumerated().forEach { i, _ in
      group.enter()
      let flags: DispatchWorkItemFlags = (i == 2) ? .barrier : []
      queue.async(group: group, execute: itemClosure(i, flags, {
        group.leave()
      }))
    }
    
    group.notify(queue: queue) {
      DispatchQueue.main.async {
        print("end")
      }
    }
  }
}
复制代码

这里使用了 GCD 来实现需求,看上去也不是特别复杂,我们还能使用 PromiseKit 来管理事件总线,不直接编写 GCD 层面的代码,使代码更简洁更易读。但是试想一下,实际需求可能更复杂,我们也许要先从服务端获取一些数据后,再下载图片并进行解码以及缓存,同时可能还会有下载音频、视频等任务要处理,这样的情况就更加复杂了。不管有没有使用 PromiseKit 这样优秀的库,随着业务的复杂度增加,都无法回避会越来越明显地暴露出来的问题:

  • 闭包本身难以阅读,还有导致循环引用的潜在风险
  • 回调必须覆盖各种情况,一旦遗漏则难以排查问题所在
  • Result 虽然较好地处理了错误,但难以解决错误向上传递的问题
  • 嵌套层级太深导致回调地狱
  • ......

async/await 初体验

针对上面的这些问题,Concurrency 的解决方案是使用 async/await 模式,该模式在 C#、Javascript 等语言中有着成熟的应用。现在,我们终于可以在 Swift 中使用它了!

下面是使用 async/await 改造 fetchImage 的代码,这里先了解一下 asyncawait 关键字的基本使用:

  • async:添加在函数末尾,标记其为异步函数
  • await:添加在调用 async 函数前,表明该处的代码会受到阻塞,直到异步事件返回
func fetchImage(idx: Int) async throws  -> UIImage { // 1
  let request = URLRequest(url: .init(string: from[idx])!)
  // 2
  let (data, resp) = try await URLSession.shared.data(for: request)
  // 3
  print(idx, Thread.current)
  guard (resp as? HTTPURLResponse)?.statusCode == 200 else {
    throw FetchImageError.badNetwork
  }
  guard let image = UIImage(data: data) else {
    throw FetchImageError.downloadFailed
  }
  return image
}
复制代码
  1. async throws 表明该函数是异步的、可抛出错误的

  2. URLSession.shared.data 方法的全名如下,因此我们需要使用 try await 来调用该方法

    public func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse)
    复制代码
  3. 代码执行到这里时,表明下载图片的异步事件已经结束了

相信你对 async/await 的使用已经有点感觉了:async 用来标记异步事件,await 用来调用异步事件,等待异步事件返回,然后继续执行后面的代码。它和 throws、try 这对关键词很像,几乎总是同时出现在相关场合。有的读者可能会纳闷,为何 try await 和 async throws 的顺序是反的,这里不必纠结,设计如此罢了,而且 try await 好像听上去和写起来更顺一点?

接下来我们要做的就是调用异步函数 fetchImage,并且需要控制图片的下载顺序,实现代码:

// 1
async let image0 = try? fetchImage(idx: 0)
async let image1 = try? fetchImage(idx: 1)
// 2
let images = await [image0, image1]
imageViews[0].image = images[0]
imageViews[1].image = images[1]
// 3
imageViews[2].image = try? await fetchImage(idx: 2)
复制代码
  1. async let 可以让多个异步事件同时执行,这里表示同时异步下载前两张图片。

    前面我们说了 async 用来标记异步函数,await 用来调用,几乎总是出现在同一场合。而且编译器会去检查调用 async 函数时是否使用了 await,如果没有,则会报错。而这里,我们在调用 fetchImage 时并没有使用 await,依然可以通过编译,是因为在使用 async let 时,如果我们没有显示地使用 try await,Swift 会隐式的实现它,而且能将 try await 的调用时机推迟。

    上面的代码,我们将它改成如下也是可以的:

    async let image0 = fetchImage(idx: 0)
    async let image1 = fetchImage(idx: 1)
    let images = try await [image0, image1]
    复制代码
  2. await 阻塞当前任务,等待上面的两个异步任务返回结果

  3. 前两张图片下载完成之后,继续异步下载第三张图片并展示

将上面的代码放在 viewDidLoad 中执行,发现凡是有 async 的地方都报红了。这是因为如果某个函数内部调用了 async 函数,该函数也需要标记为 async,这样才能为函数体内部提供异步环境,并且将异步事件进行传递。而 viewDidLoad 没有被标记为 async,编译器发现了这一问题并报错了。但是,我们不能这样做。因为 viewDidLoad 是重写的 UIViewController 中的方法,它是运行在主线程中的同步函数而且必须如此。

那么这个问题该如何解决呢?Swift 为我们提供了 Task,在创建的 Task 实例闭包中,我们将获得一个新的异步环境,如此,就可以调用异步函数了。Task 就像打破同步环境结界的桥梁,为我们提供了通向异步环境的通道。

我们将上面的代码放在 Task 实例的闭包中,就可以顺利运行程序了。

Task {
  // 1
  async let image0 = fetchImage(idx: 0)
  async let image1 = fetchImage(idx: 1)
  // 2
  let images = try await [image0, image1]
  imageViews[0].image = images[0]
  imageViews[1].image = images[1]
  // 3
  imageViews[2].image = try? await fetchImage(idx: 2)
}
复制代码

上面的代码最终的表现结果和改造前还有点细微差别:前两张图片虽然是同时异步下载的,但是会相互等待,直到两张图片都下载完成后,才展示在界面上。这里提供两个思路去实现与之前同样的效果,一是将展示图片的逻辑放在 fetchImage 方法中,另一种是使用 Task 解决,参考代码如下:

Task {
  let task1 = Task {
    imageViews[0].image = try? await fetchImage(idx: 0)
  }
  let task2 = Task {
    imageViews[1].image = try? await fetchImage(idx: 1)
  }
  let _ = await [task1.value, task2.value]
  imageViews[2].image = try? await fetchImage(idx: 2)
}
复制代码

关于 Task、TaskGroup 并不在本文的讨论范畴,后面会有单独的章节去详述。

这里要补充说明的是,当我们使用 async let 时,实际上是在当前任务中隐式地创建了一个新的 task,或者叫子任务。async let 就像一个匿名的 Task,我们没有显示地创建它,也不能使用本地变量存储它。所以 Task 相关的 value、cancel() 等属性和方法,我们都无法使用。

async let 其实就是一个语法糖,我们可以使用它应对多数场景下的异步事件处理。如果要处理的异步事件数量多且关系复杂,甚至涉及到事件的优先级,那么使用 Task、TaskGroup 是更明智的选择。

Refactor to Async

如果你想把之前基于回调的异步函数迁移至 async/await(最低支持 iOS 13),Xcode 内置了非常方便的操作,能够快速地进行零成本的迁移和兼容。

如图所示,选中相应的方法,右键选择 Refactor,会有三种选择:

  1. Convert Function to Async:将当前的回调函数转换成 async,覆盖当前函数
  2. Add Async Alternative:使用 async 改写当前的回调函数,基于改写后的函数结合 Task 再提供一个回调函数
  3. Add Async Wrapper:保留当前的回调函数,在此基础上提供一个 async 函数

从上我们可以得知 Wrapper 支持的 iOS 版本范围是大于 Alternative 的,我们可以根据项目的最低支持版本按需操作:

  • < iOS 13,选 3
  • >= iOS 13
    • 整体迁移至 async:选 1
    • 保留回调函数 API:选 3 或 1

小结

async/await 简化了异步事件的处理,我们无需和线程直接打交道,就可以写出安全高效的并发代码。回调机制经常衍生出的面条式代码也不复存在,我们可以用线性结构来清晰地表达并发意图。

这得益于结构化并发的编程范式在背后做理念支撑,结构化并发的思想和结构化编程是类似的。每个并发任务都有自己的作用域,并且有着明确且唯一的入口和出口。不管这个并发任务内部的实现有多复杂,它的出口一定是单一的。

我们把要执行并发任务想象成一根管道,水流就是管道内要执行的任务。在非结构化编程的世界,子任务会生成许多的管道分支,水流会从不同的分支出口流出去,也可能会遇到故障,我们需要在不同的出口去处理水流结果,出口越多,我们越手忙脚乱。而结构化编程的世界里,我们无需关心各个分支出口,只要守住管道另一端的唯一出口就可以了,分支出口不管多复杂,水流最终会回到管道的出口。

猜你喜欢

转载自juejin.im/post/7054058830304870414