引入
Swift 内置支持以结构化方式编写异步和并行代码。异步代码可以暂停并在稍后恢复,但一次只能执行一段程序。暂停和恢复程序中的代码可以让程序在更新用户界面等短期操作上继续取得进展,同时继续进行网络数据获取或文件解析等长期运行的操作。并行代码意味着多段代码同时运行—例如,配备四核处理器的计算机可以同时运行四段代码,每个内核执行其中一个任务。使用并行和异步代码的程序一次执行多个操作,并暂停等待外部系统的操作。
并行或异步代码带来的额外调度灵活性也带来了复杂性增加的代价。Swift 可以让您以实现某些编译时检查的方式来表达您的意图,例如,您可以使用行为体来安全地访问可变状态。但是,为缓慢或有错误的代码添加并发性并不能保证代码会变得快速或正确。事实上,添加并发性甚至会使代码更难调试。不过,在需要并发的代码中使用 Swift 语言级的并发支持,意味着 Swift 可以帮助您在编译时发现问题。
定义和调用异步函数
异步函数或异步方法是一种特殊的函数或方法, 在执行过程中可以暂停。 这与普通的同步函数和方法截 然不同, 后者要么运行完成, 要么抛出错误, 要么永远不返回。 异步函数或方法仍会执行这三件事中 的一件, 但它也可以在等待某件事情时中途暂停。 在异步函数或方法的主体中, 你可以标记每一个可以 暂停执行的地方。
要表明函数或方法是异步的, 可以在其声明中的参数后写入 async
关键字, 这与使用 throws
标记抛出函数的方法类似。 如果函数或方法返回一个值, 则在返回箭头 (->) 前写入 async
。
func listPhotos(inGallery name: String) async -> [String] {
let result = // ... some asynchronous networking code ...
return result
}
对于既是异步又是抛出的函数或方法, 应在抛出前写入 async
。
调用异步方法时, 执行会暂停, 直到该方法返回。 在调用之前写 await
,以标记可能的暂停点。
这就像在调用抛出函数时写 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 的代码需要能够暂停执行, 因此程序中只有某些地方可以调用异步函数或方法:
- 异步函数、方法或属性的主体中的代码。
- 标有 @main 的结构体、类或枚举的静态 main() 方法中的代码。
- 非结构化子任务中的代码。
你可以通过调用 Task.yield() 方法显式插入一个暂停点。
func generateSlideshow(forGallery gallery: String) async {
let photos = await listPhotos(inGallery: gallery)
for photo in photos {
// ... render a few seconds of video for this photo ...
await Task.yield()
}
}
假设渲染视频的代码是同步的, 那么它就不包含任何中止点。 渲染视频的工作也可能需要很长时间。 不过,您可以定期调用 Task.yield() 来明确添加暂停点。 通过这种方式构建长时间运行的代码, Swift 可以在这项任务取得进展和让程序中的其他任务取得进展之间取得平衡。
在编写简单代码学习并发如何工作时, Task.sleep方法非常有用。该方法至少在给定的时间内暂停 当前任务。
func listPhotos(name: String) async throws -> [String] {
try await Task.sleep(for: .seconds(2))
return ["a", "b", "c"]
}
let result = try await listPhotos(name: "hehe")
print(result) // ["a", "b", "c"]
异步函数与抛掷函数有一些相似之处: 定义异步函数或抛掷函数时, 会用 async 或 throws 标记, 并用 await 或 try 标记对该函数的调用。 异步函数可以调用另一个异步函数, 就像抛掷函数可以 调用另一个抛掷函数一样。
不过两者之间的重要区别是: 你可以使用do-catch封装代码, 处理错误。 但是没有安全的方法封装 异步代码。
func availableRainyWeekendPhotos() -> Result<[String], Error> {
return Result {
try listDownloadedPhotos(inGallery: "A Rainy Weekend")
}
}
Swift标准库有意省略了这种不安全的功能—自己尝试实现它可能会导致微妙的竞赛、线程问题和死锁 等问题。 在现有项目中添加并发代码时, 应自上而下进行。 具体来说, 首先转换最顶层的代码以使 用并发功能, 然后开始转换它所调用的函数和方法 ,一次转换一层项目架构。 自下而上的方法是行不 通的,因为同步代码永远无法调用异步代码。
异步序列
上一节中的 listPhotos函数是在数组的所有元素都准备就绪后, 一次性异步返回整个数组。 另一种方法是使用异步序列每次等待集合中的一个元素。下面是异步序列的迭代过程:
import Foundation
let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
print(line)
}
for-await-in 循环可能会在每次迭代开始时暂停执行,因为它在等待下一个元素可用。
通过添加对 AsyncSequence
协议的一致性也可以在 for-await-in 循环中使用自己的类型。
并行调用异步函数
使用 await
调用异步函数时, 一次只能运行一段代码。 当异步代码运行时, 调用者会等待
该代码完成, 然后继续运行下一行代码。
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下 载一张图片, 一张完成, 下一张才开始。 这些照片应该是可以同时下载的。
要调用异步函数让它与周围代码并行运行, 可在定义常量时的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 的三次调用都是在不等待前一次调用完成的情况下开始的。如果有足够的系 统资源, 它们可以同时运行。 这些函数调用都没有标记 await,因为代码不会暂停以等待函数的结果。 相反,执行会一直持续到定义照片的那一行—此时,程序需要这些异步调用的结果,所以要写 await 来暂停执行,直到三张照片都下载完毕。
这两种方法的区别:
- 当后面的代码依赖该函数的结果时, 使用await调用函数, 创建顺序执行工作。
- 当代码稍后才需要结果时, 使用async-let调用异步函数, 将创建并行执行工作。
await 和 async-let 都允许暂停时运行其他代码。 你也可以在同一代码中混合使用两种方法。
任务和任务组
任务是一个工作单元, 可以作为程序的一部分异步运行。 所有异步代码都作为某个任务的一部分运行。 任务本身一次只做一件事, 但当您创建多个任务时, Swift 可以安排它们同时运行。
上一节中 async-let 语法会隐式地创建一个子任务—当你已经知道程序需要运行哪些任务时,这种 语法就能很好地发挥作用。 你还可以创建一个任务组(TaskGroup 的实例), 并显式地将子任务添加 到该组中, 这样您就可以对优先级和取消进行更多控制, 并创建动态数量的任务。
任务按层级排列。 给定任务组中的每个任务都有相同的父任务, 每个任务都可以有子任务。由于任务 和任务组之间存在明确的关系, 这种方法被称为结构化并发。任务之间明确的父子关系有几个优点:
- 在父任务中,不能忘记等待子任务完成。
- 在为子任务设置更高的优先级时,父任务的优先级会自动升级。
- 取消父任务时,其每个子任务也会自动取消。
- 任务本地值会自动有效地传播到子任务中。
下面是下载照片代码的另一个版本,可以处理任意数量的照片。
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 会同时运行多个这些任务。 一旦子任务完成下载, 就会显示该照片。 子任务完成的顺序没有保 证,因此该图库中的照片可以按任何顺序显示。
对于返回结果的任务组, 你可以添加代码, 在闭包中累计结果。
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并发任务使用合作取消模型。 每个任务都会在执行过程中的适当时间点检查自己是否已被取消, 并对取消做出适当的响应。 根据任务正在执行的工作, 对取消做出响应通常意味着以下情况之一:
- 抛出类似 CancellationError 的错误
- 返回 nil 或空集合
- 返回部分完成的工作
如果图片较大或网络速度较慢, 下载图片可能需要很长时间。 为了让用户停止这项工作, 而无需等待
所有任务完成, 任务需要检查是否取消, 如果取消则停止运行。 任务有两种方法可以做到这一点:
调用 Task.checkCancellation()
类型方法或读取 Task.isCancelled
类型属性。如果任
务被取消, 调用 checkCancellation()
会抛出错误; 抛出错误的任务会将错误传播到任务之外,
停止任务的所有工作。 这样做的好处是易于实现和理解。 如果想获得更大的灵活性, 可以使用 isCancelled
属性, 在停止任务的同时执行清理工作, 如关闭网络连接和删除临时文件。
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
方法添加,以避免在取消任务后 启动新工作。 - 每次调用
addTaskUnlessCancelled
后, 代码都会确认新的子任务已添加。 如果组被取 消,则added
的值为 false, 在这种情况下, 代码会停止尝试下载其他照片。 - 每个任务在开始下载照片前都会检查是否取消。 如果已取消, 则任务返回 nil。
- 最后, 任务组在收集结果时会跳过 nil 值。 通过返回 nil 来处理取消, 意味着任务组可以返回 部分结果, 即在取消时已经下载的照片, 而不是丢弃已完成的工作。
对于需要立即通知取消的工作, 使用 Task.withTaskCancellationHandler
方法。
let task = await Task.withTaskCancellationHandler {
// ...
} onCancel: {
print("Canceled!")
}
// ... some time later...
task.cancel() // Prints "Canceled!"
非结构化并发
Swift支持非结构化并发。 与作为任务组一部分的任务不同, 非结构化任务没有父任务。 你可以完全
灵活地按程序需要的方式管理非结构化任务, 但也要对其正确性负完全责任。 要创建一个在当前角色上
运行的非结构化任务, 请调用 Task.init(priority:operation:)
初始化程序。 要创建不属于
当前角色的非结构化任务, 更具体地说就是分离任务, 请调用 Task.detached(priority:operation:)
类方法。这两种操作都会返回一个可以与之交互的任务,例如,等待其结果或取消它。
let newPhoto = // ... some photo data ...
let handle = Task {
return await add(newPhoto, toGalleryNamed: "Spring Adventures")
}
let result = await handle.value
Actors
你可以使用任务将程序分割成独立的并发片段。 任务之间相互隔离, 因此可以安全地同时运行, 但有时您需要在任务之间共享一些信息。 Actors可以让你在并发代码之间安全地共享信息。
Actors是引用类型, 因此《类是引用类型》中关于值类型和引用类型的比较也适用于Actors。 与类不同的是, actor 一次只允许一个任务访问其可变状态, 这使得多个任务中的代码可以安全 地与同一个 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
关键字引入角色, 在一对大括号中定义角色。
TemperatureLogger具有其他代码可以访问的属性, 也限制了 max 属性, 只有内部代码可以 更新最大值。
使用与类相同的语法创建Actor实例。 访问角色属性或方法时, 使用 await 标记潜在暂停点。
let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)
// Prints "25"
Actor的代码在访问属性是不用写 await。
extension TemperatureLogger {
func update(with measurement: Int) {
measurements.append(measurement)
if measurement > max {
max = measurement
}
}
}
update不包含任何暂停点, 所以其他代码无法在更新过程中访问数据。 如果尝试直接访问, 就会出现编译错误。
print(logger.max) // Error
Swift保证只有Actor中的代码才能访问本地状态, 这种保证称为行为体隔离。
Swift并发模型的几个方面共同作用, 使得共享状态推理更加容易:
- 在可能的暂停点之间的代码按顺序运行。
- 与本地状态有关的代码只在该行为体上运行。
- 一个角色一次只能运行一段代码。
extension TemperatureLogger {
func convertFahrenheitToCelsius() {
measurements = measurements.map { measurement in
(measurement - 32) * 5 / 9
}
}
}
扩展将华氏温度转化为摄氏温度的方法。 当单位转换进行时, 其他代码无法读取部分转换的数据。
可发送类型
通过任务和角色, 你可以将程序划分为可以安全并发运行的部分。 在任务或角色实例内部, 程序中 包含变量和属性等可变状态的部分称为并发域。 某些类型的数据不能在并发域之间共享, 因为这些数 据包含可变状态,但并不能防止重叠访问。
可以从一个并发域共享到另一个并发域的类型称为可发送类型。 例如, 它可以在调用行为方法时作为 参数传递, 也可以作为任务结果返回。 本章前面的示例没有讨论可发送性, 因为这些示例使用的是 简单的值类型, 在并发域之间共享数据总是安全的。 相反, 有些类型在并发域之间传递并不安全。 例如, 一个包含可变属性的类, 如果不将对这些属性的访问序列化, 那么在不同任务之间传递该类的 实例时,就会产生不可预测的错误结果。
你可以通过声明符合 Sendable
协议来标记可发送类型。 该协议没有任何代码要求, 但有 Swift
强制执行的语义要求。一般来说,类型有三种可发送的方式:
- 该类型是值类型, 其可变状态由其他可发送数据组成—例如,具有可发送存储属性的结构或具有 可发送关联值的枚举。
- 该类型没有任何可变状态, 其不可变状态由其他可发送数据组成—例如,只有只读属性的结构或类。
- 该类型有确保其可变状态安全的代码, 如标记为 @MainActor 的类或在特定线程或队列上序列化 其属性访问的类。
有些类型总是可发送的, 比如结构只有可发送的属性, 枚举只有可发送的关联值等。
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
, 因此它是
隐式可发送的。
struct TemperatureReading {
var measurement: Int
}
要显式地将类型标记为不可发送, 可以使用扩展。
struct FileDescriptor {
let rawValue: CInt
}
@available(*, unavailable)
extension FileDescriptor: Sendable { }
上面的代码显示了 POSIX 文件描述符的部分封装。 尽管文件描述符接口使用整数来识别打开的文件 并与之交互, 而且整数值是可发送的, 但文件描述符并不能安全地跨并发域发送。
FileDescriptor 是一个符合隐式可发送标准的结构。 然而, 扩展使其与 Sendable 的一致性不 可用, 从而阻止了该类型的可发送性。