并发
执行异步操作。
Swift 原生支持结构化的异步和并行代码。
异步代码是能够被暂时挂起并在稍后继续执行的代码,不过在同一时刻中只有一段程序代码执行。通过挂起和恢复代码,你的程序就可以在执行耗时很长的任务时抽空执行一些快速的操作,比如在下载文件、解析文件的过程中更新 UI。并行代码则意味着多段代码可以在同一时刻执行;例如,一台拥有四核处理器的电脑可以同时运行四段代码(每个核心执行一项任务)。一款运用并行和异步代码编写的程序可以同时运行多个任务,且可以在等待外部系统处理时,暂时挂起一些任务。
并发和异步代码在增加调度灵活性的同时也会增加复杂度。Swift 能够在你在编写异步代码时,提供一些编译时检查——例如,你可以使用 actor 来安全地访问可变状态。然而,为一段运行缓慢或是有错误的代码添加并发能力,并不一定就能使它变得更快速或者更正确地运行。事实上,简单地为代码增加并发能力甚至可能导致代码问题更难排查。不过,对于的确有必要并发执行的代码来说,Swift 语言级别的并发支持能帮助你在编译时就捕捉到错误。
本章剩余的部分将使用并发一词指代异步和并行代码这一常见的组合。
你当然也可以不用 Swift 原生支持去写并发的代码,但这样代码的可读性会下降。比如,下面的这段代码会拉取一系列图片名称的列表,下载列表中的第一张图片然后展示给用户:
listPhotos(inGallery: "Summer Vacation") { photoNames in
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
downloadPhoto(named: name) { photo in
show(photo)
}
}
即便是编写这样一个简单的案例,代码中都不可避免地需要使用一系列的完成回调,这导致出现了多层嵌套的闭包。可想而知,使用这种方式编写更复杂的代码会产生更深的嵌套,从而使得代码迅速变得臃肿、难以阅读。
定义和调用异步函数
异步函数 或 异步方法 是一种能在运行中被挂起的特殊函数或方法。相比之下,普通的同步函数和方法只能持续运行到完成、抛出错误,或是永远不返回。异步函数或方法也能做到这三件事,但多出了等待其他资源时暂停执行的能力。在异步函数或者方法的代码块中,你需要明确标注这些可以暂停执行的位置。
要将一个函数或方法标记为异步,你需要在函数 / 方法签名的参数列表后边加上 async 关键字 —— 这和使用throws 关键字来标记可抛出错误的函数十分相似。如果你的函数或方法有返回值,你需要将 async 添加在返回箭头 (->) 的前面。比如,下面这段代码会从图库中提取照片名:
func listPhotos(inGallery name: String) async -> [String] {
let result = // ... some asynchronous networking code ...
return result
}
对于既是异步又可抛出错误的方法或函数,请将 async 写在 throws 的前面。
在调用异步方法时,当前方法的执行会被暂时挂起,直到被调用异步方法返回。你需要在调用前增加 await 关键字来标记此处为可能的挂起点(suspension point)。这就和在调用会抛出错误的方法前需要添加 try 一样 —— 为了标记在发生错误时,程序的执行流程可能发生变化。在一个异步方法中,方法只会在调用另一个异步方法时被挂起 —— 挂起永远不会是隐式或抢占式的,所有可能的挂起点都会用 await 明确地标注出来。这样一来,并发代码的可读性就获得了提升。
举个例子,下面这段代码会读取图库中所有图片的名称,然后展示第一张图片:
let photoNames = await listPhotos(inGallery: "Summer Vacation")
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
let photo = await downloadPhoto(named: name)
show(photo)
由于 listPhotos(inGallery:) 和 downloadPhoto(named:) 这两个方法都需要发起网络请求,他们都可能耗时较长。为这两个函数在返回箭头前加上 async 可以将它们定义为异步函数,从而使得这部分代码在等待图片下载时,程序中的其他部分可以继续运行。
为了更好理解上面这段代码的并发本质,下面列举出这段程序其中一种可能的执行顺序:
代码中被 await 标记的挂起点表明当前这段代码可能会暂停等待异步方法或函数的返回。这也被称为让出线程,因为在幕后 Swift 会挂起这段代码在当前线程的执行,转而让其他代码在当前线程执行。因为有 await 标记的代码可以被挂起,所以在程序中只有特定的地方才能调用异步方法或函数:
你还可以通过调用 Task.yield() 来显式地插入挂起点。
[Task.yield()]: https://developer.apple.com/documentation/swift/task/3814840-yield
func generateSlideshow(forGallery gallery: String) async {
let photos = await listPhotos(inGallery: gallery)
for photo in photos {
// ... 为这张照片渲染一段几秒钟的视频 ...
await Task.yield()
}
}
假设在上面这段代码中,渲染视频的部份是同步执行的,它其中不会包含任何挂起点。但是,渲染视频的任务又可能耗时很长。这种情况下,你就可以通过调用 Task.yield() 来手动添加挂起点。你可以通过以这种结构编写长时间运行的代码,来协助 Swift 在任务执行上取得平衡 —— 在长耗时的任务上取得进展的同时,也给程序中的其他任务提供了执行的机会。
在学习并发编程时, [Task.sleep(for:tolerance:clock:)][] 这个方法非常有用。这个方法会将当前任务挂起至少指定的时长。以下是 listPhotos(inGallery:) 这个函数的另一个版本,它使用 sleep(for:tolerance:clock:) 来模拟等待网络请求:
[Task.sleep(for:tolerance:clock:)]: https://developer.apple.com/documentation/swift/task/sleep(for:tolerance:clock:)
func listPhotos(inGallery name: String) async throws -> [String] {
try await Task.sleep(for: .seconds(2))
return ["IMG001", "IMG99", "IMG0404"]
}
这个版本的 listPhotos(inGallery:) 既是异步的,又可能抛出错误,因为 Task.sleep(until:tolerance:clock:) 可能抛出错误。当你调用这个版本的 listPhotos(inGallery:) 时,你需要同时添加 try 和 await:
let photos = try await listPhotos(inGallery: "A Rainy Weekend")
异步函数和可抛出错误的函数有一些相似点:当你定义一个异步或是可抛出错误的函数时,你不仅需要添加 async 或者 throws,还需要在调用这些方法的代码处添加 await 或 try。一个异步函数可以调用另一个异步函数,就像一个可抛出错误的函数也可以调用另一个可抛出错误的函数。
但是,这里有一个非常重要的区别。你可以通过用 do-catch 包裹可抛错误的代码来处理错误;也可以使用 Result 来存储这个错误,以便将错误交由其他地方的代码来处理。这个方法可以让你在不抛错误的函数里调用可抛错误的函数。例如:
func availableRainyWeekendPhotos() -> Result<[String], Error> {
return Result {
try listDownloadedPhotos(inGallery: "A Rainy Weekend")
}
}
相比之下,并没有什么安全的方式可以让你在同步代码里执行异步代码并等待结果。因此,Swift 的标准库特意舍弃了这样不安全的功能 —— 如果你执意要自己编写这样的逻辑,最终很有可能导致难以排查的数据竞争、线程问题,或是死锁。当你向现有项目添加并发代码时,请自顶向下进行。特别地,请先将最顶层的代码转换为异步的,然后再开始转换被其调用的其他代码,以此类推一层一层地向下转换。自底向上改造代码的方法是不可能的,因为同步代码无法调用异步代码。
异步序列
前文的 listPhotos(inGallery:) 方法会在异步地准备好整个数组的所有元素后,一次性返回整个数组;另一种方式是使用异步序列 (asynchronous sequence) 在每个元素就绪的当下都将其返回。下面这段代码展示了如何遍历一个异步序列:
import Foundation
let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
print(line)
}
在这个事例中,我们使用 for 和 await 来代替了普通的 for-in 循环。和你调用异步函数或方法一样,在这里 await 也标注了潜在的挂起点。一个 for-await-in 循环在每一轮迭代的开头都有可能挂起,以便等待序列中下一个元素的就绪。
正如同你可以在 for-in 循环中通过遵从 [Sequence][] 协议来使用自定义类型一样,你也可以在 for-await-in 循环中通过遵循 [AsyncSequence] 来使用自定义类型。
[Sequence]: https://developer.apple.com/documentation/swift/sequence
[AsyncSequence]: https://developer.apple.com/documentation/swift/asyncsequence
并行调用异步函数
使用 await 来调用异步方法时,在同一时刻只会有一段代码运行。在一段异步代码运行的过程中,调用方会先等待其返回,然后才会执行下一行代码。举个例子,如果你想要读取图库中的前三张照片,你可以像下方这样轮流等待三次对 downloadPhoto(named:) 调用的返回:
let firstPhoto = await downloadPhoto(named: photoNames[0])
let secondPhoto = await downloadPhoto(named: photoNames[1])
let thirdPhoto = await downloadPhoto(named: photoNames[2])
let photos = [firstPhoto, secondPhoto, thirdPhoto]
show(photos)
这种方式有一个显著的缺点:尽管下载过程是异步的,并且其他的任务也可以在下载过程中继续执行,但每次只有一个 downloadPhoto(named:) 的调用会运行 —— 每张照片都只有在上一张照片完成下载后才会开始下载。然而,这些任务其实没有必要相互等待:每张照片都可以独立下载,甚至是在同一时间下载。
要想在调用异步函数时,允许其与周围的代码并行执行,你可以在使用 let 定义一个常量时,在前方添加 async 标注。然后,你需要在使用此常量时,添加 await 标记。
async let firstPhoto = downloadPhoto(named: photoNames[0])
async let secondPhoto = downloadPhoto(named: photoNames[1])
async let thirdPhoto = downloadPhoto(named: photoNames[2])
let photos = await [firstPhoto, secondPhoto, thirdPhoto]
show(photos)
在这个例子中,所有三次对 downloadPhoto(named:) 的调用都会立即开始,而不等待前一个调用返回。如果系统此时有足够的可用资源,三个下载任务会并行执行。注意到在这三次函数调用中,我们都没有使用 await 标注,因为这些调用并不会导致代码执行挂起。代码持续执行到 photos 被定义的那一行时,你才需要 await 来暂停程序执行,因为程序需要前面的调用结果才能为 photos 赋值。
你可以这样理解这两种方式的区别:
你也可以在同一段代码中混合使用两种方法。
任务和任务组
一项任务 (task)是一个单元的工作,且可以作为程序的一部分异步执行。所有的异步代码都在某一项任务中执行。一项任务本身只能完成一件事情,但当你创建多个任务时,Swift 可以让他们同时运行。
上一节中的 async-let 会隐式地创建一项子任务 —— 如果你已经知道程序需要执行什么任务,这种语法十分便捷。你也可以创建一个任务组(TaskGroup 的实例)然后显式地向其中添加子任务。这可以让你更好地控制优先级和任务取消,也可以让你动态决定要创建多少任务。
[TaskGroup]: https://developer.apple.com/documentation/swift/taskgroup
任务的排列具有层级结构。同一个任务组中的所有任务都具有相同的父任务, 且他们也可以拥有自己的子任务。考虑到任务和任务组之间具有这种显式的关系,我们将这种范式称为结构化并发 (structured concurrency)。这种任务间的显式父 - 子关系有几种优势:
在下面这个代码示例中,我们可以处理任意数量的照片下载任务:
await withTaskGroup(of: Data.self) { group in
let photoNames = await listPhotos(inGallery: "Summer Vacation")
for name in photoNames {
group.addTask {
return await downloadPhoto(named: name)
}
}
for await photo in group {
show(photo)
}
}
上面的代码创建了一个新的任务组,并创建了一些子任务,每个任务会下载一张照片。Swift 会在条件许可的情况下,尽可能多地并行执行这些任务。在某一项子任务完成下载后,其对应的照片就会立即被显示出来。不过,这些子任务会以任意顺序完成执行,所以这些照片的最终展示顺序也会是随机的。
在上面的代码中,每张照片都会被下载然后展示出来,所以任务组没有返回值。对于需要返回结果的任务组,你可以在传递给 withTaskGroup(of:returning:body:) 的闭包中,编写聚合任务结果的逻辑。
let photos = await withTaskGroup(of: Data.self) { group in
let photoNames = await listPhotos(inGallery: "Summer Vacation")
for name in photoNames {
group.addTask {
return await downloadPhoto(named: name)
}
}
var results: [Data] = []
for await photo in group {
results.append(photo)
}
return results
}
如同之前的范例,这个示例中为每张照片创建了一个下载子任务。与之前不同的是,这里的 for-await-in 循环会等待下一个子任务结束,将其的结果插入到结果数组中,然后继续等待所有子任务完成。最后,这个任务组会将所有下载完毕的照片数组作为一个整体返回。
任务取消
Swift 中的并发使用的是「协作取消」模型:每个任务都应当在合适的位置检查其是否已被取消,然后对取消指令做出合理的响应。取决于任务所执行工作的性质,通常有这几种方式来响应取消指令:
如果图片较大或网络较慢,图片下载可能耗时非常长。要允许用户取消这项事务,而不必等待所有任务完成,这些任务必须要检查取消指令,并在收到指令时停止运行。有两种方式可以做到这件事:调用 Task.checkCancellation() 方法,或是检查 Task.isCancelled 属性。如果任务已被取消,调用 checkCancellation() 会抛出错误;一项可抛错误的任务可以将错误向外扩散,并停止任务中的所有工作。这样做的好处是代码编写简单、理解成本更低。要获得更多的灵活度,你可使用 isCancelled 属性,这样你就可以在停止任务的过程中,做一些例如关闭网络连接、清理临时文件的清理工作。
[Task.checkCancellation()]: https://developer.apple.com/documentation/swift/task/3814826-checkcancellation
[Task.isCancelled type]: https://developer.apple.com/documentation/swift/task/iscancelled-swift.type.property
let photos = await withTaskGroup(of: Optional<Data>.self) { group in
let photoNames = await listPhotos(inGallery: "Summer Vacation")
for name in photoNames {
let added = group.addTaskUnlessCancelled {
guard !Task.isCancelled else { return nil }
return await downloadPhoto(named: name)
}
guard added else { break }
}
var results: [Data] = []
for await photo in group {
if let photo { results.append(photo) }
}
return results
}
上面这段代码相比前一个版本有几个变动:
- 每项任务都使用 [
TaskGroup.addTaskUnlessCancelled(priority:operation:)][] 方法来添加,
以避免在任务取消之后产生新的任务。
- 在每次调用
addTaskUnlessCancelled(priority:operation:)之后,
这段代码都会确认子任务的确已经添加成功了。如果任务组已被取消,那么 added 的值就会为 false —— 这种情况下,代码会不再尝试下载更多的照片。
[TaskGroup.addTaskUnlessCancelled(priority:operation:)]: https://developer.apple.com/documentation/swift/taskgroup/addtaskunlesscancelled(priority:operation:)
[Task.isCancelled instance]: https://developer.apple.com/documentation/swift/task/iscancelled-swift.property
如果你的事务需要在被取消时立即收到提醒,可以使用 [Task.withTaskCancellationHandler(operation:onCancel:isolation:)][] 方法。例如:
[Task.withTaskCancellationHandler(operation:onCancel:isolation:)]: https://developer.apple.com/documentation/swift/withtaskcancellationhandler(operation:oncancel:isolation:)
let task = await Task.withTaskCancellationHandler {
// ...
} onCancel: {
print("Canceled!")
}
// ... 一段时间之后 ...
task.cancel() // 输出 "Canceled!"
在你使用取消回调时,任务取消依然是协作的:任务要么一直执行到完成,要么主动检查取消指令并提早停止。因为在取消回调开始执行时,任务本身依然还在运行,请注意避免在任务和取消回调之间共享状态 —— 这可能导致数据竞争。
非结构化并发
除了像前文所述那样以结构化的方式编写并发逻辑,Swift 也支持非结构化并发。不像从属于某个任务组的任务,一项非结构化的任务没有父任务。管理非结构化任务时,你将拥有最大的灵活性,可以按任意方式组织他们。但是,你也将需要对他们的正确性承担全部责任。要在当前 actor 上创建一项非结构化的任务,你可调用 [Task.init(priority:operation:)][] 这个构造器。要创建一项不属于当前 actor 的非结构化任务 —— 也被称作为分离 (detached) 任务 —— ,请调用 [Task.detached(priority:operation:)][] 这个类方法。这两项操作都会返回 task 实例,便于你管理他们。比如,你可以等待他们的返回结果,也可以取消他们:
let newPhoto = // ... some photo data ...
let handle = Task {
return await add(newPhoto, toGalleryNamed: "Spring Adventures")
}
let result = await handle.value
要了解更多有关如何管理分离任务的信息,请查看 Task.
[Task.init(priority:operation:)]: https://developer.apple.com/documentation/swift/task/init(priority:operation:)-7f0zv
[Task.detached(priority:operation:)]: https://developer.apple.com/documentation/swift/task/detached(priority:operation:)-d24l
Actors
你可以使用任务来将自己的程序分割为相互独立、并行的片段。任务之间时相互隔离的,这样他们才能安全地同时运行。但有时候,你需要在任务之前共享信息。此时,你就可以使用 actors 来安全地在并行代码之间共享这些信息。
就和类一样,actor 也是应用类型,所以在
actor TemperatureLogger {
let label: String
var measurements: [Int]
private(set) var max: Int
init(label: String, measurement: Int) {
self.label = label
self.measurements = [measurement]
self.max = measurement
}
}
你可通过 actor 关键字和紧随其后的括弧来定义一个 actor。TemperatureLogger actor 具有可供外部代码访问的属性,也有 max 这个只有 actor 内部代码才能修改的属性。
你可通过使用与结构体和类相同的构造体语法来创建一个 actor 实例。在你访问一个 actor 的属性或方法时,需要使用 await 来表明这是一个潜在的挂起点。比如:
let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)
// Prints "25"
在这个例子中,对 logger.max 的访问是一个可能的挂起点。这是因为其 actor 同一时刻只允许一项任务访问其可变状态。如果另一项任务正与 logger 进行交互,这段代码就需要先挂起,直到轮到他来访问这个属性。
相比之下,actor 内部的代码不需要使用 await 来访问 actor 的属性。比如,这是一个用于更新 TemperatureLogger 所记录温度的方法:
extension TemperatureLogger {
func update(with measurement: Int) {
measurements.append(measurement)
if measurement > max {
max = measurement
}
}
}
update(with:) 这个方法已经是在 actor 上运行的了,所以它在访问 max 这样的属性时,不需要使用 await 来进行标注。这个方法也揭示了 actor 同一时刻仅允许一项任务与其可变状态交互的其中一项原因:有些对于 actor 状态的更新会暂时打破不变式。TemperatureLogger 这个 actor 记录了一系列温度数据以及一项最高温度,并且它应当会在记录一项新的测量数据时,更新最高温度纪录。在一次更新操作插入新的测量数据之后、更新最高温度纪录之前的那一刻,我们的温度记录器 actor 暂时处于一个非法的状态中。禁止多个任务同时与某个实例交互能够在下列事件序列中,防止问题的出现:
在这种情况下,其它地方的代码会读取到错误的信息,因为他们对 actor 的访问被插入在了 update(with:) 执行过程的中间,从而读取到了不合法的数据。使用 Swift actor 能让你避免这种情况,因为它同一时刻只允许一项对其状态的操作,且代码执行只能在有 await 标注的挂起点处被打断;又因为 update(with:) 并不包含任何挂起点,其执行过程中没有任何其它代码可以访问数据。
如果 actor 之外的代码尝试直接访问这些属性,编译器会报错。比如:
print(logger.max) // Error
不加 await 访问 logger.max 会失败,因为一个 actor 的属性是这个 actor 的本地受隔离状态的一部分。想要访问该属性的代码必须在此 actor 上执行,这是一个异步操作,所以必须用 await 标注。Swift 保证了只有在 actor 上运行的代码才能访问这个 actor 的本地状态。这种保证称为 actor 隔离。
Swift 并发模型的以下几个特点共同降低了使用者对共享可变属性的理解成本:
基于这些保证,处于一个 actor 之内、且不包含 await 的方法可以安全地对 actor 状态进行更新,而不用担心程序中的其它部分意外读取到不合法状态。
例如,下面这段代码会将测量到的温度从华氏度转换到摄氏度:
extension TemperatureLogger {
func convertFahrenheitToCelsius() {
for i in measurements.indices {
measurements[i] = (measurements[i] - 32) * 5 / 9
}
}
}
上面这段代码会逐个转换数组内的测量数据。在 map 操作的执行过程中,有些温度还是华氏度,有些则已经转换为了摄氏度。但是,因为其中没有任何代码包含 await,这里不会出现潜在的挂起点。这个方法所修改的状态从属于 actor,actor 为这个状态提供了保护,使其免受不在 actor 上运行的代码的读取或修改。这意味着其它代码不可能有办法读取到单位转换过程中,只被转换了一半的测量数据。
除了将代码编写在一个 actor 中,并在其中舍弃掉所有的挂起点之外,还有更进一步的方式来避免出现临时性的不合法状态:将这些代码移动到一个同步方法中。上方的 convertFahrenheitToCelsius() 方法就是一个同步方法,保证了它永远不可能包含挂起点。这个方法封装了会暂时造成不合法数据状态的代码,从而使得阅读代码的人更容易意识到:在这个方法完成自己的任务、并将合法的数据状态被恢复之前,没有其它代码可以运行。在未来,如果你试图向这个方法中添加并发代码,或是一个可能的挂起点,编译器会及时报错,以防引入新 bug。
可发送类型
任务和 actor 能让你将一个程序分成多个小段病安全地并行运行。一个任务或是一个 actor 的实例内部所包含的可变状态(例如变量或属性),被称为并发域。有些数据无法在不同的并发域之间共享,因为这些数据包含可变状态,但其又无法对重叠访问提供保护。
对于可以被从一个并发域共享到另一个并发域的类型,被称作可发送类型。例如,它可以在调用一个 actor 时被作为参数传递,或是作为一项任务的返回值返回。本章前述的几个例子没有讨论可发送性,因为这些例子使用的都是简单的值类型,而这些类型永远是可以被安全地在并发域之前传递的。相比之下,有些类型无法被安全地在并发域之间传递。比如,一个包含了可变属性、但又没有添加串行访问保护的类,如果在不同任务之间传递,可能会产生无法预测或是错误的结果。
你可以通过使一个类型遵循 Sendable 协议来将其标注为可发送的。这个协议并不包含任何代码要求,但是其包含了由 Swift 强制实施的语义要求。总的来说,有三种方式能让一个类变得可发送:
若想了解所有的语义要求,请参见 Sendable 协议指南。
有些类型总是可发送的,比如只包含可发送属性的结构体,或是只包含可发送关联值的枚举。比如:
struct TemperatureReading: Sendable {
var measurement: Int
}
extension TemperatureLogger {
func addReading(from reading: TemperatureReading) {
measurements.append(reading.measurement)
}
}
let logger = TemperatureLogger(label: "Tea kettle", measurement: 85)
let reading = TemperatureReading(measurement: 45)
await logger.addReading(from: reading)
由于 TemperatureReading 是个结构体,并且只包含可发送属性,又因为这个结构体没有被标注为 public 或 @usableFromInline,它是隐式可发送的。下面这个版本的结构体隐式地遵从了 Sendable 协议:
struct TemperatureReading {
var measurement: Int
}
要显式地标注一个类型为不可发送,你可以通过扩展来覆写对 Sendable 的隐式遵从:
struct FileDescriptor {
let rawValue: CInt
}
@available(*, unavailable)
extension FileDescriptor: Sendable { }
上面这段代码是 POSIX 文件描述符包装器的其中一部分。尽管文件描述符的接口使用整数来识别和处理打开的文件,且整数值是可发送的,文件描述符并不能够被安全地在并发域之间传递。
在上面的代码中,FileDescriptor 这个结构体符合隐式可发送的条件。但是,我们使用扩展来将其对于 Sendable 的遵从标注为不可用,从而防止其称为一个可发送类型。