我们知道,Swift 5.5 中最重要的一部分更新,就是来自语言层面的并发支持。但如果你抱着走马观花的态度去浏览一下这些知识就会发现,无论是 WWDC sessions,还是 Swift evolution list 中的相关内容,很容易就会把你搞晕了。因为围绕并发这个话题的新概念很多,例如:continuation,AsyncSequence,concurrent value,concurrent closure,structured concurrency,actors,effectful readonly property 等等。似乎有了这些特性之后,我们一下子都不会写 Swift 了。

以上,说的就是我刚刚开始学习这些内容时的感受。你越是想尽快掌握它们,学习它们的压力就会让你觉得越发不爽。因此,我们需要一个相对来说条理清晰的学习路线。这个系列的视频,就是为此准备的。

作为和并发相关内容的开始,我们不妨换一个视角,站在设计 Swift 编译器的立场,来聊聊基于 callback 的异步处理方式究竟有多么不完美呢?

回调地狱

首先,就是回调地狱,这个我们都很熟悉,为了能按逻辑顺序执行一系列异步代码,我们只好把函数调用一层层的写在 callback 里:

func processNews1(
  url: URL, 
  completionHandler: (_ article: Article) -> Void) {
  downloadArticle(url) { article in
    saveToDatabase(article) { article in
      completionHandler(article)
    }
  })
})

例如,在上面这个处理 RSS 订阅的函数里,为了实现解析种子、下载文章并保存到本地的过程,我们就嵌套了三层回调函数。

捉襟见肘的错误处理

当然,你可能觉得,这样的代码除了缩进稍微丑一点其实也没什么,但这只限于一切都不会出错的情况。一旦我们把 Swift 的错误处理机制整合进来,可就不是丑一点的问题了。继续来看下面的例子:

func processNews2(
  url: URL, 
  completionHandler: (_ article: Article?, _ error: Error?) -> Void) {
  downloadArticle(url) { article, error in
    guard let article = article else {
      completionHandler(nil, error)
      return
    }
    
    saveToDatabase(article) { article, error in
      guard let article = article else {
        completionHandler(nil, error)
        return
      }
      
      completionHandler(article)
    }
  })
})

这次,我们假设 callback 都接受两个参数,分别是正常情况下的 Article 以及表达错误的 Error。为了在进入下一个异步环节之前确保结果正常,我们就得在每一个 callback 里用 guard let 先确认下结果。显然,如果你是 Swift guard 机制的设计者,你也不会满意这样的用法。

除了 guard 之外,Swift 中另外一个和错误处理有关的设施是异常,并且,Swift 还特别为了处理异步方法中的错误定义了 Result 类型。但它们在我们例子中的表现,却并不理想:

func processNews3(
  url: URL,
  completionHandler: (Result<Article, Error>) -> Void) {
  do {
    downloadArticle(url) { result in
      do {
        let article = try result.get()
        saveToDatabase(article) { result
          do {
            let article = try result.get()
            completionHandler(.success(article))
          }
          catch {
            completionHandler(.failure(error))
          }
        }
      }
      catch {
        completionHandler(.failure(error))
      }
    }
  }
  catch {
    completionHandler(.failure(error))
  }
}

当然,我们也可以用 switch...case... 来处理每个 callback 中的 result

saveToDatabase(article) { result
  switch result {
    case .success(let article):
      completionHandler(.success(article))
    case .failure(let error):
      completionHandler(.failure(error))
  }
}

可以看到,无论是嵌套 do,还是嵌套 switch,在稍微复杂一点儿的异步环境里,用来处理错误都显得太容易出错了。

有条件地执行异步代码

看到这里,我们还只有一条异步执行的逻辑(解析 -> 下载 -> 保存)。如果我们还要根据特定条件执行异步语句,情况就会更加复杂。假设,在加载出文章之前,我们希望在界面上先显示出 placeholder,然后再显示文章:

func loadNews(
  url: URL,
  completionHandler: (Result<Article, Error>) -> Void) {
  let swizzle: (_ article: Article) -> Void = {
    // Switch placeholder to article
  }
  
  if cache[url] != nil {
    swizzle(cache[url]!)
  }
  else {
    processNews3(url: url) { result in
      do {
        let article = result.get()
        swizzle(article)
      }
      catch {
        print(error)
      }
    }
  }
}

loadNews 的实现里,如果文章本地有缓存,我们就直接用它替换掉 placeholder,否则,就用之前定义的 processNews 方法。于是,我们就有了两个替换 placeholder 的地方,一个是同步完成的,一个是异步完成的。为此,我们把这个替换的过程,定义成了 swizzle

这种把后面才执行的代码写到一开始的做法,本质上和一开始展示的回调地狱是相同的。设想一下我们有好几个条件分支,每个都有可能执行不同的回调函数,我们就可能在函数一开始定义非常多的 closure,这同样会导致代码难以理解和调试。以至于人们给这种写法起了一个类似的名字,叫做 inverted pyramid of doom。

硬伤之外的小毛病

除了上面这些比较严重的硬伤之外,使用回调函数的时候还容易在结构复杂之后犯一些难以察觉的错误。例如之前 processNews2 的实现,就可能在调用 completionHandler 之后,忘了使用 return 语句:

func processNews4a(
  url: URL, 
  completionHandler: (_ article: Article?, _ error: Error?) -> Void) {
  downloadArticle(url) { article, error in
    guard let article = article else {
      completionHandler(nil, error)
      /// Forget `return`
    }
    
    saveToDatabase(article) { article, error in
      guard let article = article else {
        completionHandler(nil, error)
        /// Forget `return`
      }
      
      completionHandler(article)
    }
  })
})

或者,直接 return 忘了调用 completionHandler

func processNews4b(
  url: URL, 
  completionHandler: (_ article: Article?, _ error: Error?) -> Void) {
  downloadArticle(url) { article, error in
    guard let article = article else {
      /// Forget `completionHandler(nil, error)`
      return
    }
    
    saveToDatabase(article) { article, error in
      guard let article = article else {
        /// Forget `completionHandler(nil, error)`
        return
      }
      
      completionHandler(article)
    }
  })
})

Callback 的长远影响

虽然没有明确的数据证明,但我们有理由相信,上面提到的所有 callback 的问题,多多少少会让一些 API 的设计者选择放弃 callback,甚至放弃让自己的 API 支持异步操作的语义,哪怕这些 API 会阻塞线程。这种做法在客户端,就可能引起 UI 卡顿,在服务端,则会影响通过异步机制提升性能的方案。显然,它们都不是我们想要的结果。

理想的方法是什么呢?

理想的方法,是我们可以用和同步代码类似的方式,表达异步代码的执行过程,对我们的 processNews 来说,就是类似这样的:

func downloadArticle(_ url: URL) async throws -> Article
func saveToDatabase(_ article: Article) async throws -> Void {}
func displayAndCleanUp(_ article: Article) async throws -> Void {}

func loadNews(url: URL) async throws -> Article {
  var article = try await downloadArticle(url)
  try await saveToDatabase(article)
  try await displayAndCleanUp(article)
  
  return article
}

我们用 async 标记可能会异步执行的函数,用 await 表示等待异步执行结束后再继续执行。这样,无论是异步代码还是同步代码,它们的逻辑顺序以及表达方式,就是相同的了。这样一来,Swift 语言的各种语言设施也就都可以正常地应用到异步执行的代码了。而这,正是我们接下来要提到的 Swift async/await

What's next?

好了,之所以在正式介绍 async/await 之前说了这么多,是希望让你相信,在 Swift 中加入它们不仅仅是一个锦上添花的新功能。了解了 callback 的这些缺陷之后,接下来,我们就来看看为了把 async/await 加入 Swift,我们都应该考虑哪些问题。

所有订阅均支持 12 期免息分期

¥ 59

按月订阅

一个月,观看并下载所有视频内容。初来泊学,这可能是个最好的开始。

开始订阅

¥ 512

按年订阅

一年的时间,让我们一起疯狂地狩猎知识吧。比按月订阅优惠 28%

开始订阅

¥ 1280

泊学终身会员

永久观看和下载所有泊学网站视频,并赠送 100 元商店优惠券。

我要加入
如需帮助,欢迎通过以下方式联系我们