背景
在过往的 iOS 开发中,异步执行任务需要用到回调和闭包来实现。基于回调的异步编程有很多弊端,例如:
-
如果需要连续执行多个异步任务,很容易产生如下的多层嵌套,影响可读性。
swiftfunc processImageData1(completionBlock: (_ result: Image) -> Void) { loadWebResource("dataprofile.txt") { dataResource in loadWebResource("imagedata.dat") { imageResource in decodeImage(dataResource, imageResource) { imageTmp in dewarpAndCleanupImage(imageTmp) { imageResult in completionBlock(imageResult) } } } } }
-
处理代码抛出的异常 / 错误进行非常麻烦。如下图所示,每一层都需要用
guard
来检查错误,并调用完成回调。swiftfunc processImageData2a(completionBlock: (_ result: Image?, _ error: Error?) -> Void) { loadWebResource("dataprofile.txt") { dataResource, error in guard let dataResource = dataResource else { completionBlock(nil, error) return } loadWebResource("imagedata.dat") { imageResource, error in guard let imageResource = imageResource else { completionBlock(nil, error) return } decodeImage(dataResource, imageResource) { imageTmp, error in guard let imageTmp = imageTmp else { completionBlock(nil, error) return } dewarpAndCleanupImage(imageTmp) { imageResult, error in guard let imageResult = imageResult else { completionBlock(nil, error) return } completionBlock(imageResult) } } } } }
-
假设我们需要根据某些条件改变回调的传参,为了避免大量重复的代码,我们就不得不先定义
swizzle
这个闭包。这会导致代码被「倒转」过来:swizzle
的代码原本是在后面执行,但却先于执行它的代码出现,提高了理解成本。此外,我们还必须非常小心地关注回调里持有了哪些变量。swiftfunc processImageData3(recipient: Person, completionBlock: (_ result: Image) -> Void) { let swizzle: (_ contents: Image) -> Void = { // ... } if recipient.hasProfilePicture { swizzle(recipient.profilePicture) } else { decodeImage { image in swizzle(image) } } }
-
容易踩坑:忘记调用完成回调,或者是忘记 return
swiftfunc processImageData4a(completionBlock: (_ result: Image?, _ error: Error?) -> Void) { loadWebResource("dataprofile.txt") { dataResource, error in guard let dataResource = dataResource else { return // <- 忘了调用 completionBlock } loadWebResource("imagedata.dat") { imageResource, error in guard let imageResource = imageResource else { return // <- 忘了调用 completionBlock } ... } } } func processImageData4b(recipient:Person, completionBlock: (_ result: Image?, _ error: Error?) -> Void) { if recipient.hasProfilePicture { if let image = recipient.profilePicture { completionBlock(image) // <- 忘了 return } } ... }
考虑到以上原因,回调的异步编程太容易出错。这导致许多程序员在编程时潜意识地不愿意编写异步代码,错失了很多性能优化的机会。
async/await
基于协程(coroutine)模型,Swift 的 async/await 机制让我们能像编写同步代码一样编写异步代码。这不仅能极大的提高代码可读性和 debug 的便利性,还能为未来推出的 Swift 并发特性提供基石。前文提到的、基于回调的异步代码用 async/await 重写之后会变得简洁明了:
swift
func loadWebResource(_ path: String) async throws -> Resource
func decodeImage(_ r1: Resource, _ r2: Resource) async throws -> Image
func dewarpAndCleanupImage(_ i : Image) async throws -> Image
func processImageData() async throws -> Image {
let dataResource = try await loadWebResource("dataprofile.txt")
let imageResource = try await loadWebResource("imagedata.dat")
let imageTmp = try await decodeImage(dataResource, imageResource)
let imageResult = try await dewarpAndCleanupImage(imageTmp)
return imageResult
}
可以这么理解 Swift 的 async 异步函数:
- 异步函数本质上依然是普通的函数,但它们还被赋予了「暂时让出当前线程」的能力;相比之下,同步函数不具有此项能力,它们只懂得阻塞当前线程,直到其中的所有代码执行完毕。
- 由于同步函数并不知道如何「让出」当前线程,一个同步函数不能够直接调用另一个异步函数。
- 异步函数可以依然可以调用阻塞当前线程的其它同步函数。当一个异步函数执行到它自身或是它所调用的另一个异步函数的挂起点时,这条链路上的所有函数都会同时「挂起」,并让出当前线程给其它任务使用。
- 当挂起的异步函数恢复执行时,它并不一定会回到被挂起前的线程。在编写 Swift 的并发逻辑时,我们应该避免考虑线程 ------ 这是底层的实现细节。在 Swift 的并发模型中,所有的方法都在某一个 actor 中执行。Swift 只保证异步函数在恢复后会回到原本的 actor 中执行。
- Actor 的细节在本文中暂不讨论,它的作用类似于 GCD 中的串行队列。不同的是,actor 并不保证先进先出。
挂起点 Suspension points
在一个异步函数的执行过程中,其必须暂停执行并「让出」当前线程的时刻被称之为「挂起点」。在 Swift 中,挂起点总是显式、明确的 ------ 一个函数中所有可能的挂起点总会被明确地用await
标注出来。(注意,标注了await
的函数调用反过来并不一定就是一个挂起点 ------ 当前函数在编译时并不知道其所调用的函数是否真的会「挂起」。)
为何对「挂起点」的标注如此重要?因为挂起操作会破坏代码执行的「原子性」------ 从函数被挂起到恢复之间,当前的 actor 上可能已经运行了其它代码。举个例子:假设同一笔银行交易 A 包含了「存款 + 取款」两步,但在「存款」之后 A 交易的处理被暂时挂起了,那么不怀好意的人就有机会在挂起期间再度发起一笔「取款」B 交易。在 A 交易执行被恢复后,A 交易中的取款被继续执行 ------ 这导致一笔存款被取出了两次。
设计细节
以下是 async/await 的一些语法设计细节。
异步方法
swift
// 将一个函数标注为异步
func collect(function: () async -> Int) { ... }
// 构造方法也可以是异步的
class Teacher {
init(hiringFrom: College) async throws {
...
}
private func raiseHand() async -> Bool {
...
}
}
在 async/await 的提案中,包括deinit
和属性的访问器(getter、setter、下标)在内的特殊方法不能是异步的。
在后来的SE-0310
提案中 Swift 增加了对异步只读属性的支持,但包含 setter 的属性仍然不能是异步的。async/await 原提案中对此的解释是:由于一个可写属性可能被作为inout
参数传递,该属性中包含的子属性可能在别处被访问或修改,而这意味着该可写属性的 setter 必须是同步且不会抛出任何异常的。SE-0310
提案中则进一步提到,考虑到inout
、_modify
、didSet
、willSet
、属性包装器、下标等等特性的存在,要支持异步可写属性工程量实在是十分巨大,所以暂不考虑支持。
对于没有调用父类构造方法的子类异步构造方法,编译器仅会在父类包含一个无参数、同步、被指定的构造方法时,隐式添加super.init()
。这是因为如果父类构造器也是异步的,那么对父类构造方法的调用构成了一个「挂起点」,而这必须被显式地标注出来。
异步方法类型转换
可以将同步方法隐式转换为异步方法,但反过来则不行:
swift
struct FunctionTypes {
var syncNonThrowing: () -> Void
var syncThrowing: () throws -> Void
var asyncNonThrowing: () async -> Void
var asyncThrowing: () async throws -> Void
mutating func demonstrateConversions() {
// OK
asyncNonThrowing = syncNonThrowing
asyncThrowing = syncThrowing
syncThrowing = syncNonThrowing
asyncThrowing = asyncNonThrowing
// Error
syncNonThrowing = asyncNonThrowing
syncThrowing = asyncThrowing
syncNonThrowing = syncThrowing
asyncNonThrowing = syncThrowing
}
}
Await 语句
所有可能的「挂起点」仅能出现在异步上下文中(例如在一个异步方法中),且必须用await
进行标注:
swift
// func redirectURL(for url: URL) async -> URL { ... }
// func dataTask(with: URL) async throws -> (Data, URLResponse) { ... }
let newURL = await server.redirectURL(for: url)
let (data, response) = try await session.dataTask(with: newURL)
一个await
可以包含多个潜在的挂起点:
swift
let (data, response) = try await session.dataTask(with: server.redirectURL(for: url))
await
本身并不具备任何功能,它仅仅起到标注潜在 挂起点的作用。一个被标注了await
的对象也可能并不包含任何挂起点:
swift
let x = await synchronous() // 警告:标注为 await 但并没有调用任何异步方法
await
不能用在defer
的闭包中。
闭包
闭包也可以是异步的:
swift
{ () async -> Int in
print("here")
return await getInt()
}
包含await
的匿名闭包会被隐式标注为异步:
swift
let closure = { await getInt() } // 隐式异步
let closure2 = { () -> Int in // 隐式异步
print("here")
return await getInt()
}
一个嵌套闭包被隐式标注为异步,并不会导致其上下级闭包被一同标注为异步:
swift
// func getInt() async -> Int { ... }
let closure5 = { () -> Int in // 非异步
let closure6 = { () -> Int in // 隐式异步
if randomBool() {
print("there")
return await getInt()
} else {
let closure7 = { () -> Int in 7 } // 非异步
return 0
}
}
print("here")
return 5
}
过载函数时的冲突解决策略
现状
以下两个函数虽然表面上具有不同的签名,但调用时的代码却可能出现重叠:
swift
func doSomething(completionHandler: ((String) -> Void)? = nil) { ... }
func doSomething() async -> String { ... }
doSomething() // 调用哪个?
按照现有的 Swift 过载函数策略,参数较少的那个函数(也就是第二个)会被调用。这导致开发者如果新增函数 2,所有原先的doSomething()
代码都会立刻报错 ------ 它们都会被改为调用函数 2,但函数 2 作为一个async
异步方法,不能够在同步方法里被执行。这会给开发者造成巨大的迁移成本,因为他们将不得不在「改名方法 1,所有原来的的doSomething()
代码立刻报错」和「给所有的新增async
方法想一个新的名字」之间二选一。
继续考虑如下情况:
swift
// 现有的同步接口
func doSomethingElse() { ... }
// 新的异步接口
func doSomethingElse() async { ... }
// 错误: 重复定义 `doSomethingElse()`.
由于现有的 Swift 过载规则不允许两个方法签名完全一致、仅在「最终效果」上产生区别,这样的方法定义是不合法的。这也导致了迁移成本,因为开发者没办法在保留现有同步接口的情况下,新增同名的异步接口。
改进的过载策略
随着 async/await 一同推出的新过载策略会根据上下文来判断要执行哪个方法:
- 在一个同步的上下文中,Swift 会优先选择两个重名方法中的同步版本。这是因为在同步的上下文中,调用异步版本是不合法的。
- 在一个异步的上下文中,Swift 会优先选择异步版本,因为异步代码应该尽可能避免调用会导致阻塞的同步代码。此时,
await
关键字自然也是必不可少的。
明白了这些新的策略,我们再来看看上一节提到的 doSomething
方法:
swift
func f() async {
// 因为处于异步上下文中,异步版本的 doSomething 被选择了:
await doSomething()
// 错误:因为选择了异步的 doSomething,必须添加 await
doSomething()
}
func f() async {
let f2 = {
// 处于同步上下文中,同步版本的 doSomething 被选择了:
doSomething()
}
f2()
}
自动闭包
一个方法自身必须是async
,它所接受的自动闭包参数才能够是async
的。
swift
// 错误:方法不是 async,参数中却有 async 自动闭包
func computeArgumentLater<T>(_ fn: @escaping @autoclosure () async -> T) { }
为什么会有这种限制?考虑以下代码:
swift
// func getIntSlowly() async -> Int { ... }
let closure = {
computeArgumentLater(await getIntSlowly())
print("hello")
}
这种写法有几个问题:
await
关键字出现在了传参里,这会导致开发者误认为挂起点是在computeArgumentLater(_:)
被执行之前,但实际上并非如此 ------getIntSlowly()
是在computeArgumentLater(_:)
内部被调用的。- 基于第 1 点,由于
await
关键字的特殊位置,closure
会被认为是异步的,但实际上closure
里全都是同步代码。 - 似乎应该写成
await computeArgumentLater(getIntSlowly())
,但考虑到参数是个自动闭包,这样又破坏了语义的完整性。
通过把async
自动闭包限定为仅可在异步上下文中使用,我们即可避免这些问题。
协议
async
的协议方法要求可以被同步或异步的方法所实现,但协议中所规定的同步方法不得被async
实现所满足。这和throw
的协议要求类似。
swift
protocol Asynchronous {
func f() async
}
protocol Synchronous {
func g()
}
struct S1: Asynchronous {
func f() async { } // OK
}
struct S2: Asynchronous {
func f() { } // OK,同步方法可满足协议的异步方法要求
}
struct S3: Synchronous {
func g() { } // OK
}
struct S4: Synchronous {
func g() async { } // 错误:异步方法无法满足协议的同步方法要求
}