2.18 并发

原文链接: https://docs.swift.org/latest/documentation/the-swift-programming-language/concurrency/

2.18 并发

执行异步操作。

Swift 内置了对以结构化方式编写异步和并行代码的支持。异步代码可以被挂起并在之后恢复,尽管同一时刻只有一段程序在执行。程序中的代码被挂起和恢复,使它能够在处理网络取数据、解析文件这类长时间运行的操作的同时,继续推进更新界面之类的短期操作。并行代码意味着多段代码同时运行——例如,一台四核处理器的电脑可以同时运行四段代码,每个核心执行其中一个任务。使用并行和异步代码的程序同时执行多个操作,并会把等待外部系统的操作挂起。本章其余部分用并发一词来指代异步代码与并行代码的这种常见组合。

并行或异步代码带来的额外调度灵活性,代价是复杂性增加。编写并发代码时,你事先并不知道哪些代码会同时运行,也未必总知道代码的运行顺序。并发代码中常见的问题是多段代码试图访问同一处共享可变状态——这称为数据竞争。使用语言层面的并发支持时,Swift 会检测并阻止数据竞争,大多数数据竞争会产生编译期错误;有些数据竞争要到代码运行时才能发现,这类数据竞争会终止代码执行。你使用 actor 和隔离来防止数据竞争,本章将对此进行说明。

注意:如果你写过并发代码, 可能已经习惯了与线程打交道。 Swift 中的并发模型建立在​​线程之上, 但你不会直接与线程交互。 Swift 中的异步函数 可以放弃它正在运行的线程, 让另一个异步函数在第一个函数被阻塞时使用该线程。 当异步函数恢复时, Swift 不保证该函数会在哪个线程上运行。

虽然不使用 Swift 的语言支持也能编写并发代码,但那样的代码往往更难阅读。例如下面的代码下载一份照片名字列表、下载列表中的第一张照片,并把这张照片显示给用户:

1
2
3
4
5
6
7
listPhotos(inGallery: "Summer Vacation") { photoNames in
    let sortedNames = photoNames.sorted()
    let name = sortedNames[0]
    downloadPhoto(named: name) { photo in
        show(photo)
    }
}

即使在这么简单的例子里,由于代码必须以一连串完成处理器的形式书写,最终你还是写了一层层嵌套的闭包。在这种风格下,嵌套更深的复杂代码很快就会变得难以驾驭。

定义和调用异步函数

异步函数或异步方法是一种特殊的函数或方法,它可以在执行到一半时被挂起。这与普通的同步函数和方法相对,后者要么运行到结束,要么抛出错误,要么永不返回。异步函数或方法仍然会做这三件事之一,但它还可以在等待某件事时中途暂停。在异步函数或方法的语句体内部,你要标记出这些可以挂起执行的位置。

要表示某个函数或方法是异步的,就在它的声明中把 async 关键字写在参数之后,类似于用 throws 标记抛出错误的函数。如果函数或方法返回值,就把 async 写在返回箭头(->)之前。例如,下面这样获取某个相册中照片的名字:

1
2
3
4
func listPhotos(inGallery name: String) async -> [String] {
    let result = // ... 一些异步网络代码 ...
    return result
}

对于既异步又抛出错误的函数或方法,把 async 写在 throws 之前。

调用异步方法时,执行会挂起,直到该方法返回。在调用前写 await 来标记这个可能的挂起点,就像调用抛出函数时写 try 来标记出错时程序流程可能发生变化一样。在异步方法内部,只有在调用另一个异步方法时执行流程才可能被挂起——挂起永远不是隐式或抢占式的——这意味着每个可能的挂起点都会用 await 标记出来。把代码中所有可能的挂起点都标记出来,有助于让并发代码更易读、易懂。

例如下面的代码获取某个相册中所有图片的名字,然后显示第一张图片:

1
2
3
4
5
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 把它们变成异步函数,就能让应用的其他代码在这段代码等待图片准备就绪时继续运行。

为了理解上面例子的并发特性,下面给出一种可能的执行顺序:

  1. 代码从第一行开始运行,一直到第一个 await 处;它调用 listPhotos(inGallery:) 函数,并在等待该函数返回时挂起执行。

  2. 在这段代码的执行被挂起时,同一程序中其他并发代码继续运行。例如,可能有个长时间运行的后台任务正在更新新相册列表;该代码同样会运行到下一个由 await 标记的挂起点,或者运行到结束。

  3. listPhotos(inGallery:) 返回之后,这段代码从该处继续执行,把返回的值赋给 photoNames。

  4. 定义 sortedNames 和 name 的那几行是普通的同步代码;由于这些行上没有任何 await 标记,因此不存在可能的挂起点。

  5. 下一个 await 标记了对 downloadPhoto(named:) 函数的调用:这段代码再次暂停执行,直到该函数返回,从而给其他并发代码运行的机会。

  6. downloadPhoto(named:) 返回后,它的返回值被赋给 photo,随后作为实参传给 show(_:)。

代码中由 await 标记的可能挂起点表明,当前这段代码可能在等待异步函数或方法返回时暂停执行。这也称为让出线程,因为在背后,Swift 会在当前线程上挂起你代码的执行,改为在该线程上运行其他代码。由于含有 await 的代码需要能够挂起执行,程序中只有特定位置可以调用异步函数或方法:

  • 异步函数、方法或属性的语句体中的代码。

  • 用 @main 标记的结构体、类或枚举的静态 main() 方法中的代码。

  • 非结构化子任务中的代码,如下文非结构化并发所示。

在编写用于学习并发如何工作的简单代码时,Task.sleep(for:tolerance:clock:) 方法很有用:它会把当前任务挂起至少给定的时长。下面是 listPhotos(inGallery:) 函数的一个版本,它用 sleep(for:tolerance:clock:) 模拟等待网络操作:

1
2
3
4
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:) 的调用可能抛出错误。调用这个版本时,你要同时写 try 和 await:

1
let photos = try await listPhotos(inGallery: "A Rainy Weekend")

异步函数与抛出错误的函数有一些相似之处:定义异步或抛出错误的函数时,你用 async 或 throws 标记它;调用该函数时,你用 await 或 try 标记。异步函数可以调用另一个异步函数,就像抛出错误的函数可以调用另一个抛出错误的函数一样。

不过两者有一个非常重要的区别:你可以把抛出错误的代码包进 do-catch 块来处理错误,或者用 Result 保存错误供其他代码处理,这些做法让你能从非抛出错误的代码中调用抛出函数。例如:

1
2
3
4
5
func availableRainyWeekendPhotos() -> Result<[String], Error> {
    return Result {
        try listDownloadedPhotos(inGallery: "A Rainy Weekend")
    }
}

相比之下,没有办法安全地把异步代码包起来,让你能从同步代码中调用它并等待结果。Swift 标准库有意不提供这种不安全的做法——自己尝试实现它可能导致微妙的竞争、线程问题和死锁。给已有项目添加并发代码时,请自上而下地推进:先把最上层代码改成使用并发,再逐步改造它调用的函数和方法,一层一层地按项目架构往下走。没有办法自下而上,因为同步代码永远无法调用异步代码。

异步序列

上一节中的 listPhotos(inGallery:) 函数在数组所有元素就绪之后,一次性异步返回整个数组。另一种做法是使用异步序列,一次等待集合中的一个元素。遍历异步序列的写法如下:

1
2
3
4
5
6
import Foundation

let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
    print(line)
}

与普通的 for-in 循环不同,上面的例子写的是 for 后面跟 await。和调用异步函数或方法一样,写 await 表示存在可能的挂起点。for-await-in 循环会在每次迭代开始时、也就是等待下一个元素可用时挂起执行。

正如你可以通过让自定义类型遵循 Sequence 协议把它用在 for-in 循环里一样,你也可以通过让它遵循 AsyncSequence 协议把它用在 for-await-in 循环里。

并行调用异步函数

用 await 调用异步函数时,同一时刻只运行一段代码。异步代码运行期间,调用方会等待它结束,然后才继续执行下一行代码。例如,要获取相册中的前三张照片,你可以这样依次等待三次 downloadPhoto(named:) 调用:

1
2
3
4
5
6
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:) 调用在运行,每张照片都要完全下载完之后才开始下载下一张。其实这些操作没有必要互相等待——每张照片都可以独立下载,甚至同时下载。

要调用异步函数并让它与周围的代码并行运行,就在定义常量时把 async 写在 let 前面,之后每次使用该常量时写 await。

1
2
3
4
5
6
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 可以把它们调度为同时运行。

上一节介绍的 async-let 语法会隐式创建一个子任务——当你已经知道程序需要运行哪些任务时,这种语法很好用。你也可以创建一个任务组(TaskGroup 的实例),显式地把子任务加入该组,从而对优先级和取消有更多控制,并能动态创建数量不定的任务。

任务以层级方式组织:给定任务组中的每个任务都有相同的父任务,而每个任务又可以有自己的子任务。由于任务与任务组之间有这种显式关系,这种做法称为结构化并发。任务之间显式的父子关系带来若干好处:

  • 在父任务中,你不可能忘记等待它的子任务完成。

  • 给子任务设置更高优先级时,父任务的优先级会自动提升。

  • 父任务被取消时,它的每个子任务也会自动被取消。

  • 任务局部值会高效且自动地传播到子任务。

下面是下载照片代码的另一个版本,它可以处理任意数量的照片:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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:) 的闭包中加上累积结果的代码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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 属性,它让你在停止任务的同时执行清理工作,例如关闭网络连接、删除临时文件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
let photos = await withTaskGroup { group in
    let photoNames = await listPhotos(inGallery: "Summer Vacation")
    for name in photoNames {
        let added = group.addTaskUnlessCancelled {
            Task.isCancelled ? nil : 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 实例属性, 而不是类型属性。

对于需要立即收到取消通知的工作,请使用 withTaskCancellationHandler(operation:onCancel:isolation:) 函数。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
let task = Task {
    await withTaskCancellationHandler {
        // ...
    } onCancel: {
        print("Canceled!")
    }
}

// ... 过一段时间之后……
task.cancel()  // 输出 "Canceled!"

使用取消处理器时,任务取消仍然是协作式的:任务要么运行到结束,要么检查到取消并提前停止。由于取消处理器启动时任务仍在运行,请避免在任务与其取消处理器之间共享状态,否则可能造成竞争条件。

非结构化并发

除了前几节介绍的结构化并发做法,Swift 也支持非结构化并发。与属于任务组的任务不同,非结构化任务没有父任务。你可以完全自由地按程序需要的方式管理非结构化任务,但也要完全为它们的正确性负责。

要创建与周围代码运行方式类似的非结构化任务,请调用 Task.init(name:priority:operation:) 构造器。新任务默认使用与当前任务相同的 actor 隔离、优先级和任务局部状态。要创建与周围代码更独立的非结构化任务(更准确的说法是分离任务),请调用 Task.detached(name:priority:operation:) 静态方法:新任务默认不带任何 actor 隔离运行,并且不继承当前任务的优先级和任务局部状态。这两种操作都返回一个任务,你可以与它交互——例如等待它的结果或取消它。

1
2
3
4
5
let newPhoto = // ... 一些照片数据 ...
let handle = Task {
    return await add(newPhoto, toGalleryNamed: "Spring Adventures")
}
let result = await handle.value

关于管理分离任务的更多内容,参见 Task。

隔离

前几节讨论了拆分并发工作的各种做法,这些工作可能涉及修改共享数据,例如应用的界面。如果代码的不同部分可能同时修改同一份数据,就有造成数据竞争的风险。Swift 会保护你的代码免受数据竞争的危害:每当你读取或修改某份数据时,Swift 都确保没有其他代码在并发修改它。这一保证称为数据隔离。隔离数据主要有三种方式:

  1. 不可变数据总是被隔离的。因为你无法修改常量,所以不存在其他代码在你读取它的同时修改它的风险。

  2. 只被当前任务引用的数据总是被隔离的。局部变量可以安全地读写,因为任务之外的代码没有指向该内存的引用,所以没有其他代码能修改这份数据。此外,如果你在闭包中捕获该变量,Swift 会确保该闭包不会被并发使用。

  3. 受某个 actor 保护的数据,在访问它的代码也隔离到该 actor 时是被隔离的。如果当前函数隔离到某个 actor,那么读写该 actor 保护的数据就是安全的,因为任何其他隔离到同一 actor 的代码都必须排队等待轮到自己才能运行。

主 actor

actor 是一种通过强制代码轮流访问可变数据来保护这些数据的对象。许多程序中最重要的 actor 是主 actor。在应用里,主 actor 保护所有用于展示界面的数据:主 actor 依次进行界面渲染、处理界面事件,以及运行你编写的、需要查询或更新界面的代码。

在代码中开始使用并发之前,一切都在主 actor 上运行。当你识别出长时间运行或资源消耗较大的代码时,可以把这些工作从主 actor 上移走,同时仍然保持安全与正确。

注意: 主 actor 与主线程密切相关, 但它们并不是同一个东西。 主 actor 拥有私有的可变状态, 主线程串行化对该状态的访问。 当你在主 actor 上运行代码时, Swift 会在主线程上执行这些代码。 由于这种联系, 你可能会看到这两个术语被混用。 你的代码与主 actor 交互; 主线程是更底层的实现细节。

在主 actor 上运行工作有几种方式。要确保某个函数始终在主 actor 上运行,就用 @MainActor 特性标记它:

1
2
3
4
@MainActor
func show(_: Data) {
    // ... 展示照片的界面代码 ...
}

在上面的代码中,show(_:) 函数上的 @MainActor 特性要求该函数只在主 actor 上运行。在其他运行于主 actor 的代码中,你可以把 show(_:) 当作同步函数调用;不过,如果要从不在主 actor 上运行的代码调用 show(_:),就必须写 await,把它当作异步函数调用,因为切换到主 actor 会引入一个可能的挂起点。例如:

1
2
3
4
func downloadAndShowPhoto(named name: String) async {
    let photo = await downloadPhoto(named: name)
    await show(photo)
}

在上面的代码中,调用 downloadPhoto(named:) 和 show(_:) 两个函数时都可能挂起。这段代码还展示了一个常见模式:在后台执行长时间运行、CPU 密集的工作,然后切回主 actor 更新界面。由于 downloadAndShowPhoto(named:) 函数不在主 actor 上,downloadPhoto(named:) 中的工作也不在主 actor 上运行;只有 show(_:) 中更新界面的工作在主 actor 上运行,因为该函数标记了 @MainActor 特性。

要确保闭包在主 actor 上运行,就在闭包开头、捕获列表之前和 in 之前写 @MainActor。

1
2
3
4
let photo = await downloadPhoto(named: "Trees at Sunrise")
Task { @MainActor in
    show(photo)
}

上面的代码与上一个清单中的 downloadAndShowPhoto(named:) 类似,但这里的代码不等待界面更新完成。你也可以把 @MainActor 写在结构体、类或枚举上,以确保它的所有方法以及对属性的所有访问都在主 actor 上运行:

1
2
3
4
5
@MainActor
struct PhotoGallery {
    var photoNames: [String]
    func drawUI() { /* ... 其他界面代码 ... */ }
}

上面代码中的 PhotoGallery 结构体把照片绘制到屏幕上,用其 photoNames 属性中的名字决定显示哪些照片。由于 photoNames 会影响界面,修改它的代码需要在主 actor 上运行,以串行化该访问。

当你基于某个框架构建时,该框架的协议和基类通常已经标记了 @MainActor,因此这种情况下你一般不需要在自己的类型上写 @MainActor。下面是一个简化示例:

1
2
3
4
5
@MainActor
protocol View { /* ... */ }

// 隐式为 @MainActor
struct PhotoGalleryView: View { /* ... */ }

在上面的代码里,SwiftUI 之类的框架定义了 View 协议:由于协议声明上写了 @MainActor,遵循该协议的 PhotoGalleryView 之类的类型也会被隐式标记为 @MainActor。如果 View 是基类、PhotoGalleryView 是子类,行为也一样——子类会被隐式标记为 @MainActor。

在上面的例子里,PhotoGallery 把整个结构体都保护在主 actor 上。想要更细粒度的控制,你可以只在需要在主线程上访问或运行的属性或方法上写 @MainActor:

1
2
3
4
5
6
7
struct PhotoGallery {
    @MainActor var photoNames: [String]
    var hasCachedPhotos = false

    @MainActor func drawUI() { /* ... 界面代码 ... */ }
    func cachePhotos() { /* ... 网络代码 ... */ }
}

在上面这个版本的 PhotoGallery 中,drawUI() 方法会把相册图片绘制到屏幕上,因此需要隔离到主 actor。photoNames 属性并不直接创建界面,但它存放着 drawUI() 用来绘制界面的状态,因此这个属性也只能在主 actor 上访问。相比之下,修改 hasCachedPhotos 属性并不与界面交互,cachePhotos() 方法也不访问任何需要在主 actor 上运行的状态,因此这些都没有标记 @MainActor。

和前面的例子一样,如果你使用某个框架来构建界面,该框架的属性包装器很可能已经把界面状态属性标记为 @MainActor。定义属性包装器时,如果它的 wrappedValue 属性标记了 @MainActor,那么你应用该包装器的任何属性也会被隐式标记为 @MainActor。

actor

Swift 为你提供了主 actor——你也可以定义自己的 actor。actor 让你在并发代码之间安全地共享信息。

和类一样,actor 是引用类型,因此类是引用类型中关于值类型与引用类型的比较同样适用于 actor 和类。与类不同的是,actor 在任一时刻只允许一个任务访问它的可变状态,这使得多个任务中的代码可以与同一个 actor 实例安全交互。例如,下面是一个记录温度的 actor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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 有一些属性可供 actor 外部的其他代码访问,并把 max 属性限制为只有 actor 内部的代码才能更新最大值。

创建 actor 实例的语法与结构体和类相同。访问 actor 的属性或方法时,你用 await 标记可能的挂起点。例如:

1
2
3
let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)
// 输出 "25"。

在这个例子里,访问 logger.max 是一个可能的挂起点。由于 actor 在任一时刻只允许一个任务访问它的可变状态,如果另一个任务的代码已经在与该 logger 交互,这段代码就会挂起等待,直到能够访问该属性。

相比之下,属于该 actor 的代码在访问 actor 属性时不写 await。例如,下面这个方法用新温度更新 TemperatureLogger:

1
2
3
4
5
6
7
8
extension TemperatureLogger {
    func update(with measurement: Int) {
        measurements.append(measurement)
        if measurement > max {
            max = measurement
        }
    }
}

update(with:) 方法已经在 actor 上运行,因此它不会用 await 标记对 max 之类属性的访问。这个方法也说明了 actor 为什么只允许一个任务与它的可变状态交互:对 actor 状态的某些更新会临时破坏不变式。TemperatureLogger actor 记录温度列表和最高温度,并在记录新测量值时更新最高温度;在更新过程中,追加了新测量值但还没有更新 max 的那一瞬间,温度记录器处于临时的不一致状态。防止多个任务同时与同一实例交互,就避免了下面这种事件顺序带来的问题:

  1. 你的代码调用 update(with:) 方法,它先更新 measurements 数组。
  2. 在你的代码更新 max 之前,别处的代码读取了最高温度和温度数组。
  3. 你的代码通过修改 max 完成更新。

在这种情况下,别处的代码会读到不正确信息,因为它对 actor 的访问被插在 update(with:) 调用中间、数据暂时无效的时刻。使用 Swift actor 就能避免这个问题,因为它们同一时刻只允许对自己的状态执行一个操作,而且这些代码只在 await 标记挂起点的地方才能被打断。由于 update(with:) 不包含任何挂起点,其他代码不可能在更新过程中访问这些数据。

如果 actor 外部的代码试图像访问结构体或类的属性那样直接访问这些属性,你会得到编译期错误。例如:

1
print(logger.max)  // 错误

不写 await 直接访问 logger.max 会失败,因为 actor 的属性属于该 actor 隔离的局部状态。访问这一属性的代码需要作为该 actor 的一部分运行,这是一次异步操作,必须写 await。Swift 保证只有运行在某个 actor 上的代码才能访问该 actor 的局部状态,这一保证称为 actor 隔离。

Swift 并发模型的下列几点协同工作,使共享可变状态更易于推理:

  • 可能挂起点之间的代码顺序执行,不会被其他并发代码打断;不过多段并发代码可以同时运行,因此别的代码可能正在同时执行。

  • 与 actor 局部状态交互的代码只在该 actor 上运行。

  • 一个 actor 同一时刻只运行一段代码。

由于这些保证,actor 内部不包含 await 的代码可以安全地完成更新,而不必担心程序中其他地方观察到临时无效状态。例如下面这段代码把测量的温度从华氏度转换为摄氏度:

1
2
3
4
5
6
7
extension TemperatureLogger {
    func convertFahrenheitToCelsius() {
        for i in measurements.indices {
            measurements[i] = (measurements[i] - 32) * 5 / 9
        }
    }
}

上面的代码一次转换一个测量值:转换进行过程中,有些温度已是摄氏度,有些还是华氏度。不过,由于这段代码不含 await,这个方法里没有任何可能的挂起点。它修改的状态属于该 actor,而 actor 会保护这些状态:除非代码运行在该 actor 上,否则无法读写它们。这意味着其他代码不可能在单位转换进行到一半时读到部分转换后的温度列表。

除了通过在 actor 中省略可能挂起点来保护临时无效状态之外,你还可以把这类代码放进同步方法。上面的 convertFahrenheitToCelsius() 就是一个同步方法,因此可以保证它绝不包含可能挂起点。它把"会临时让数据模型不一致"的代码封装起来,也让读代码的人更容易意识到:在完成工作、恢复数据一致性之前,不会有其他代码运行。Swift 在这段时间内不切换到程序其他部分的代码,这一点很重要。将来如果你想给这个函数加入并发代码、引入可能的挂起点,你会得到编译期错误,而不是引入一个 bug。

全局 actor

主 actor 是 MainActor 类型的全局单例实例。actor 通常可以有多个实例,每个实例提供彼此独立的隔离,这就是为什么你要把 actor 的所有隔离数据声明为它的实例属性。不过,由于 MainActor 是单例——这个类型永远只有一个实例——仅凭该类型就足以标识这个 actor,使你可以只用一个特性来标记主 actor 隔离。这种做法让你在组织代码时有更大的灵活性。

你可以用 @globalActor 特性定义自己的全局单例 actor,详见 globalActor。

可发送类型

任务和 actor 让你把程序拆分成可以安全并发运行的部分。在任务或 actor 实例内部,程序中包含可变状态(例如变量和属性)的部分称为并发域。有些数据不能在并发域之间共享,因为它们包含可变状态,却不保护重叠访问。

可以在并发域之间共享的类型称为可发送类型。例如,它可以在调用 actor 方法时作为实参传入,或者作为任务的结果返回。本章前面的例子没有讨论可发送性,因为这些例子使用的简单值类型在并发域之间传递数据时总是安全的。相比之下,有些类型跨并发域传递并不安全:例如,一个包含可变属性、且不串行化对这些属性的访问的类,在不同任务之间传递它的实例时可能产生不可预测且不正确的结果。

你通过声明遵循 Sendable 协议把某个类型标记为可发送。该协议没有任何代码层面的要求,但有 Swift 会强制执行的语义要求。总体而言,一个类型可发送有三种方式:

  • 该类型是值类型,且其可变状态由其他可发送数据构成——例如存储属性都是可发送的结构体,或者关联值都是可发送的枚举。
  • 该类型没有任何可变状态,其不可变状态由其他可发送数据构成——例如只有只读属性的结构体或类。
  • 该类型带有确保其可变状态安全的代码,例如标记了 @MainActor 的类,或者把对其属性的访问串行化在特定线程或队列上的类。

关于语义要求的详细清单,参见 Sendable 协议参考。

有些类型始终可发送,例如只有可发送属性的结构体和只有可发送关联值的枚举。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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 协议的遵循就是隐式的:

1
2
3
struct TemperatureReading {
    var measurement: Int
}

要显式把某个类型标记为不可发送,就为 Sendable 写一个不可用的遵循性:

1
2
3
4
5
6
struct FileDescriptor {
    let rawValue: Int
}

@available(*, unavailable)
extension FileDescriptor: Sendable {}

你也可以用不可用的遵循性来抑制对某个协议的隐式遵循,详见对协议的隐式遵循。