我们知道,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,我们都应该考虑哪些问题。