Swift 为以结构化方式编写异步和并行代码提供了内置支持。异步代码可以暂停和稍后恢复,尽管每次只有一部分程序执行。在程序中暂停和恢复代码可以让它继续进行短期操作(例如更新其 UI)的同时继续处理长时间运行的操作(例如通过网络获取数据或解析文件)。并行代码意味着多部分代码同时运行——例如,具有四核处理器的计算机可以同时运行四部分代码,每个核心执行一项任务。使用并行和异步代码的程序同时执行多个操作,并暂停等待外部系统响应的操作。
并行或异步代码带来的额外调度灵活性也伴随着复杂性的增加。Swift 允许你以一种能够进行部分编译时检查的方式来表达你的意图——例如,你可以使用 actors 安全地访问可变状态。然而,并发性地添加缓慢或有 bug 的代码并不能保证它会变得快速或正确。实际上,添加并发性甚至可能会让你的代码更难调试。但是,在需要并发的代码中使用 Swift 的并发语言级支持意味着 Swift 可以帮助你在编译时捕获问题。
本章其余部分将使用并发一词来指代这种常见的异步和并行代码的组合。
注意
如果您之前编写过并发代码,您可能习惯使用线程。Swift 中的并发模型是基于线程的,但您不需要直接与线程交互。在 Swift 中,一个异步函数可以放弃它正在运行的线程,这样在第一个函数被阻塞时,另一个异步函数可以在那个线程上运行。当异步函数恢复时,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
}
对于既是异步又是抛出错误的函数或方法,你可以在 throws
之前写入 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
。它会调用listPhotos(inGallery:)
函数,并在等待该函数返回时暂停执行。 - 在这段代码的执行被暂停期间,同一程序中的其他并发代码也会运行。例如,可能有一个长时间运行的后台任务继续更新新的相册列表。这段代码也会运行到下一个暂停点
await
,或者直到它完成。 - 当
listPhotos(inGallery:)
返回后,这段代码将从该点开始继续执行。它会将返回的值赋给photoNames
。 - 定义
sortedNames
和name
的代码是普通的同步代码。由于这些行上没有标记await
,因此没有可能的暂停点。 - 下一个
await
标记了对downloadPhoto(named:)
函数的调用。这段代码会再次暂停执行,直到该函数返回,从而给其他并发代码一个运行的机会。 downloadPhoto(named:)
返回后,其返回值会被赋值给photo
,然后作为参数调用show(_:)
。
代码中用 await
标记的可能的挂起点表明当前代码片段可能会在等待异步函数或方法返回时暂停执行。这也被称为让出线程,因为幕后,Swift 会暂停当前线程上你代码的执行,并在该线程上运行其他代码。由于带有 await
的代码需要能够暂停执行,因此你的程序中只能在某些地方调用异步函数或方法:
- 异步函数、方法或属性体中的代码。
- 在结构、类或枚举标记为 的静态
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 在继续处理此任务的同时,让程序中的其他任务继续处理它们的工作。
The Task.sleep(for:tolerance:clock:)
方法在编写简单代码以了解并发如何工作时很有用。此方法会挂起当前任务,至少持续给定的时间。以下使用 listPhotos(inGallery:)
函数和 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:)
函数会异步一次性返回整个数组,待数组中的所有元素都准备好之后。另一种方法是使用异步序列一次等待集合中的一个元素。以下是遍历异步序列的样子:
import Foundation
let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
print(line)
}
而不是使用普通的 for
-in
循环,上面的例子使用了 for
并在后面写了 await
。就像调用异步函数或方法时,写 await
表示一个可能的挂起点。for
-await
-in
循环在每次迭代的开始处可能暂停执行,即在等待下一个元素可用时。
同样地,你可以通过向 for
-in
循环添加对 Sequence
协议的遵循来使用你自己的类型,你也可以通过向 for
-await
-in
循环添加对 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
来暂停执行,直到所有三张照片都下载完毕。
这是这两种方法之间差异的思考方式:
- 当下面的代码依赖于该函数的结果时,使用
await
调用异步函数。这会创建按顺序执行的工作。 - 当您不需要结果直到代码的后期部分时,使用
async
-let
调用异步函数。这会创建可以并行执行的工作。
await
和 async
-let
都允许其他代码在它们暂停时运行。
在这两种情况下,您都可以使用 await
标记可能的暂停点,以指示如果需要,执行将暂停,直到异步函数返回为止。
您还可以在同一代码中混合使用这两种方法。
任务和任务组
任务是可以在程序中异步运行的工作单元。所有的异步代码都作为某个任务的一部分运行。一个任务本身一次只做一件事,但当你创建多个任务时,Swift 可以将它们安排同时运行。
The 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 会在允许的条件下尽可能多地并发运行这些任务。一旦子任务完成下载一张照片,该照片就会显示出来。子任务完成的顺序没有保证,因此画廊中的照片可以以任何顺序显示。
注意
如果下载照片的代码可能会抛出错误,你将调用
withThrowingTaskGroup(of:returning:body:)
。
在上面的代码列表中,每张照片都会被下载然后显示,因此任务组不会返回任何结果。对于返回结果的任务组,您需要添加代码,在您传递给 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 并发使用一种协作式取消模型。每个任务在其执行的适当点检查是否已被取消,并相应地响应取消。根据任务正在执行的工作,响应取消通常意味着以下之一:
- 抛出一个错误,如
CancellationError
- 返回
@0
或一个空集合 - 返回部分完成的工作
如果图片大或者网络慢,下载图片可能需要很长时间。为了让用户能够停止这项工作,而不必等待所有任务完成,任务需要检查是否被取消并在被取消时停止运行。任务可以通过两种方式做到这一点:调用 @0
类型的方法,或者读取 @1
类型的属性。调用 @2
会抛出错误,如果任务被取消;抛出错误的任务可以将错误传播出任务,停止任务的所有工作。这种方式的优点是实现和理解都很简单。为了更大的灵活性,使用 @3
属性,它允许你在停止任务时执行清理工作,比如关闭网络连接和删除临时文件。
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
——在这种情况下,代码会停止尝试下载额外的照片。 - 每个任务在开始下载照片之前都会检查是否被取消。如果被取消,任务将返回
nil
。 - 最后,任务组在收集结果时会跳过
nil
值。通过返回nil
来处理取消意味着任务组可以返回部分结果——被取消时已经下载的照片——而不是丢弃已完成的工作。
注意
为了从外部检查一个任务是否已被取消,请使用
Task.isCancelled
实例属性,而不是类型属性。
对于需要立即通知取消的工作,请使用 Task.withTaskCancellationHandler(operation:onCancel:isolation:)
方法。例如:
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
要了解有关管理分离任务的更多信息,请参阅 Task
。
执行者
你可以使用任务将程序拆分为隔离的并发部分。任务彼此隔离,这使得它们可以同时运行是安全的,但有时你需要在任务之间共享一些信息。执行者让你可以在并发代码之间安全地共享信息。
和类一样,演员也是引用类型,因此在《类是引用类型》中关于值类型和引用类型的比较同样适用于演员和类。与类不同的是,演员只允许一个任务在任何时候访问它们的可变状态,这使得来自多个任务的代码可以安全地与同一个演员实例进行交互。例如,这里有一个记录温度的演员:
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
属性,只有演员内部的代码才能更新最大值。
你使用与结构和类相同的初始化语法来创建演员的实例。当你访问演员的属性或方法时,你需要使用 await
来标记可能的挂起点。例如:
let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)
// Prints "25"
在这个例子中,访问 logger.max
是一个可能的挂起点。因为演员只允许一个任务在任何时候访问其可变状态,如果另一个任务的代码正在与日志器进行交互,这段代码将在等待访问属性时挂起。
相反,属于演员的代码在访问演员的属性时不会写入 await
。例如,这是一个更新 TemperatureLogger
的新温度的方法:
extension TemperatureLogger {
func update(with measurement: Int) {
measurements.append(measurement)
if measurement > max {
max = measurement
}
}
}
update(with:)
方法已经在演员上运行,因此它不会像对 max
这样的属性访问标记为 await
。这个方法还展示了演员为什么一次只允许一个任务与其可变状态交互的一个原因:某些对演员状态的更新暂时破坏了不变性。TemperatureLogger
演员跟踪温度列表和最大温度,并在记录新测量值时更新最大温度。在更新过程中,在追加新测量值之后但在更新 max
之前,温度记录器处于临时不一致状态。防止多个任务同时与同一实例交互可以防止以下事件序列的问题:
- 你的代码调用了
update(with:)
方法。它首先更新了measurements
数组。 - 在你的代码可以更新
max
之前,其他代码读取了最大值和温度数组。 - 代码完成更新后会通过更改
max
来实现。
在这种情况下,运行在其他地方的代码会读取到错误的信息,因为它的访问与 update(with:)
方法调用中的数据临时无效部分交织在一起。在使用 Swift 作用域时,您可以防止此问题,因为它们一次只能对其状态进行一次操作,并且由于该代码只能在 await
标记的暂停点处被中断。由于 update(with:)
不包含任何暂停点,因此没有其他代码可以在更新过程中访问数据。
如果外部作用域的代码尝试直接访问这些属性,例如访问结构体或类的属性,您将获得一个编译时错误。例如:
print(logger.max) // Error
未写入 await
而访问 logger.max
会失败,因为作用域的属性是该作用域的孤立本地状态的一部分。访问此属性的代码需要作为作用域的一部分运行,这是一个异步操作,需要写入 await
。Swift 保证只有在作用域上运行的代码才能访问该作用域的本地状态。这一保证被称为作用域隔离。
Swift 并发模型中的以下方面共同作用,使其更容易推理共享可变状态:
- 在可能的挂起点之间的代码按顺序执行,不会被其他并发代码中断。
- 与某个 actor 的本地状态进行交互的代码仅在该 actor 上运行。
- 一个 actor 一次仅运行一段代码。
由于这些保证,不包含 await
且位于 actor 内部的代码可以在不担心程序的其他部分观察到临时无效状态的情况下进行更新。例如,下面的代码将华氏温度转换为摄氏温度:
extension TemperatureLogger {
func convertFahrenheitToCelsius() {
for i in measurements.indices {
measurements[i] = (measurements[i] - 32) * 5 / 9
}
}
}
上面的代码一次将测量数组转换为华氏温度。在 map
操作进行期间,一些温度是华氏温度,而其他温度是摄氏温度。然而,由于该方法中的代码都不包含 await
,因此该方法中没有潜在的挂起点。此方法修改的状态属于 actor,它保护该状态,除非该代码在 actor 上运行,否则其他代码无法读取或修改它。这意味着在单位转换进行期间,其他代码无法读取部分转换的温度列表。
除了在保护临时无效状态的演员中编写代码并省略潜在的挂起点之外,你还可以将该代码移到同步方法中。上面的 convertFahrenheitToCelsius()
方法是一个同步方法,因此它绝不会包含潜在的挂起点。此函数封装了使数据模型暂时不一致的代码,并使任何阅读代码的人都更容易识别,在数据一致性恢复之前,没有其他代码可以运行。将来,如果你尝试向此函数添加并发代码,引入可能的挂起点,你将获得编译时错误而不是引入错误。
可发送类型
任务和演员让你可以将程序划分为可以安全并发运行的部分。在一个任务或演员的一个实例中,包含可变状态(如变量和属性)的程序部分称为并发域。某些类型的数据不能在并发域之间共享,因为这些数据包含可变状态,但无法防止并发访问。
可以在不同的并发域之间共享的类型被称为可发送类型。例如,它可以作为参数传递给调用 actor 方法,或者作为任务结果返回。本章前面的示例没有讨论可发送性,因为这些示例使用的是简单值类型,对于在并发域之间传递的数据来说,这些类型总是安全的。相比之下,有些类型在跨并发域传递时是不安全的。例如,一个包含可变属性且不序列化访问这些属性的类,在将该类的实例在不同任务之间传递时,可能会产生不可预测且错误的结果。
通过声明符合 Sendable
协议来标记类型为可发送类型。该协议没有代码要求,但 Swift 会强制执行其语义要求。一般来说,类型有三种方式可以是可发送的:
- 该类型是值类型,其可变状态由其他可发送数据组成——例如,具有可发送存储属性的结构体或具有可发送关联值的枚举。
- 该类型没有任何可变状态,其不可变状态由其他可发送的数据组成——例如,仅具有只读属性的结构或类。
- 该类型包含确保其可变状态安全性的代码,例如标记为
@MainActor
的类或在特定线程或队列上对属性访问进行序列化的类。
有关语义要求的详细列表,请参见 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
协议的遵从性不可用,从而阻止该类型成为可发送类型。