本篇是 Swift Concurrency by Example 的学习笔记。简要记录,备忘清单。推荐看原教程,配合代码示例更佳清晰易懂。


Concurrency is about dealing with many things at once, parallelism is about doing many things at once. Concurrency is a way to structure things so you can maybe use parallelism to do a better job. —— Rob Pike

默认情况下,所有 Swift 函数都是同步的。同步函数会导致阻塞,从而导致性能问题。异步函数不会阻塞。

异步函数

通过关键字 async 来创建异步函数,在异步函数内部,可以使用 await 关键字来调用其他异步函数。

  • async 是函数类型的一部分。
  • async 标记函数意味着它可能会执行异步工作,而不是必须执行异步工作。同样, throws 也是如此 - 通过函数的某些路径可能会抛出异常,但其他路径可能不会。

当使用 await 调用异步函数时,我们标记了一个挂起点,这是函数可以挂起自身的地方(实际上是停止运行),以便可以进行其他工作。在未来的某个时刻,该函数的工作完成,Swift 会将其从 “假死” 般的状态中唤醒,并继续工作。

  • 当一个异步函数被挂起时,所有调用它的异步函数也会被挂起。异步函数具有常规同步函数所没有的特殊暂停能力。正是由于这个原因,同步函数无法直接调用异步函数。
  • 一个函数可以根据需要多次挂起,使用 await 关键字。
  • 被挂起的函数不会阻塞它正在运行的线程,而是放弃该线程,以便 Swift 可以做其他工作。
  • 当函数恢复时,它可能与以前一样在同一个线程上运行,但也可能不是。

异步抛出函数

把函数标记为 async throws ,使用 try await 调用该函数。注意关键字的顺序,在函数定义中是 “异步,抛出”,但在调用站点是 “抛出,异步”。try await 不仅比 await try 更容易阅读,而且它也更能反映代码执行时实际发生的情况:我们正在等待某些工作完成,以及它何时完成完成后我们将检查它是否最终抛出错误。

This order restriction is arbitrary, but it’s not harmful, and it eliminates the potential for stylistic debates.

第一个异步函数

如果只有异步函数可以调用其他异步函数,那么是什么调用了第一个异步函数?

有三种主要方式:

  1. 在使用 @main 属性的简单命令行程序中,你可以将 main() 方法声明为异步方法。这意味着您的程序将立即启动到异步函数,因此你可以自由调用其他异步函数。
  2. 在使用 SwiftUI 等构建的应用程序中,框架本身有多个可以触发异步函数的地方。例如, refreshable()task() 修饰符都可以自由调用异步函数。
  3. Swift 提供了专用的 Task API。

调用异步函数的性能成本

同步和异步函数在内部使用不同的调用约定,异步变体的效率稍低。

每当我们使用 await 调用异步函数时,我们都会在代码中标记一个潜在的挂起点,Swift 无法在编译时判断 await 调用是否会挂起,运行时发生的情况取决于调用是否挂起:

  • 如果发生暂停,那么 Swift 将暂停该函数及其所有调用者,这会产生很小的性能成本。
  • 如果没有发生暂停,则不会发生暂停,并且您的函数将继续以与同步函数相同的效率和时序运行。

异步函数还有一个副作用:使用 await 不会导致您的代码在继续之前等待一个运行循环。相较于 DispatchQueue.main.async { … } 使用 await ,代码将立即执行。

异步属性

Swift 中,只读计算属性也可以是异步的。

1
2
3
4
var contents: T {
get async throws {
// more code to come
}

使用 async let 调用异步函数

有时您想同时运行多个异步操作,然后等待它们的结果返回,最简单的方法是使用 async let 。比如同时发起两个网络请求:

1
2
3
4
5
6
7
func loadData() async {
async let (userData, _) = URLSession.shared.data(from: URL(string: "https://hws.dev/user-24601.json")!)

async let (messageData, _) = URLSession.shared.data(from: URL(string: "https://hws.dev/user-messages.json")!)

// more code to come
}

针对上面的代码:

  • 即使 data(from:) 方法是异步的,我们也不需要在它之前使用 await 因为这是由 async let 暗示的。
  • data(from:) 方法也会抛出异常,但我们不需要使用 try 来执行它,因为它会被推迟到我们真正想要读取其返回值的时候。Swift 编译器将自动跟踪哪些 async let 常量可能引发错误,并在读取其值时强制使用 try
  • 这两个网络调用都会立即开始,但可能以任何顺序完成。

wait 和 async let 的区别

await 会等待工作完成,以便我们可以读取其结果,而 async let 则不会。

例如,如果您想要发出两个网络请求,其中一个请求与另一个请求相关,那么应当使用 await

1
2
let first = await requestFirstData()
let second = await requestSecondData(using: first)

而如果两个网络请求没有依赖关系,则可以使用 async let

1
2
3
4
5
6
func getAppData() async -> ([News], [Weather], Bool) {
async let news = getNews()
async let weather = getWeather()
async let hasUpdate = getAppUpdateAvailable()
return await (news, weather, hasUpdate)
}

async var?

Swift 的 async let 语法提供了简短、有用的语法,可以同时运行大量工作,让我们可以稍后等待它们。但是,它只能用作 async let - 不可能使用 async var

如果使用 async var 异步创建一个变量,然后修改变量的值,那么我们取消了异步工作吗?如果不是,当异步工作完成时,它会覆盖我们的新值吗?即使我们明确设置了值,在读取值时是否仍然需要使用 await ?所以只能使用 async let

Continuations

使用 Continuations,使我们能够在带有完成处理程序的旧函数和新异步代码之间创建一座桥梁。

  • CheckedContinuation: A mechanism to interface between synchronous and asynchronous code, logging correctness violations.
  • UnsafeContinuation: A mechanism to interface between synchronous and asynchronous code, without correctness checking.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct Message: Decodable, Identifiable {
let id: Int
let from: String
let message: String
}

func fetchMessages(completion: @escaping ([Message]) -> Void) {
let url = URL(string: "https://hws.dev/user-messages.json")!

URLSession.shared.dataTask(with: url) { data, _, _ in
if let data = data {
if let messages = try? JSONDecoder().decode([Message].self, from: data) {
completion(messages)
return
}
}

completion([])
}.resume()
}

func fetchMessages() async -> [Message] {
await withCheckedContinuation { continuation in
fetchMessages { messages in
continuation.resume(returning: messages)
}
}
}

let messages = await fetchMessages()
print("Downloaded \(messages.count) messages.")

注意,在程序的每个执行路径中,必须准确地调用一次 resume 方法。 否则会造成 continuation 泄露,

如果您仔细检查了代码并且确定它是正确的,那么将 withCheckedContinuation() 函数替换为对 withUnsafeContinuation() 的调用,其工作原理完全相同方式,但不会增加检查您是否正确使用延续的运行时成本。

可以抛出错的 Continuations

withCheckedThrowingContinuation()withUnsafeThrowingContinuation()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
struct Message: Decodable, Identifiable {
let id: Int
let from: String
let message: String
}

func fetchMessages(completion: @escaping ([Message]) -> Void) {
let url = URL(string: "https://hws.dev/user-messages.json")!

URLSession.shared.dataTask(with: url) { data, _, _ in
if let data = data {
if let messages = try? JSONDecoder().decode([Message].self, from: data) {
completion(messages)
return
}
}

completion([])
}.resume()
}

// An example error we can throw
enum FetchError: Error {
case noMessages
}

func fetchMessages() async -> [Message] {
do {
return try await withCheckedThrowingContinuation { continuation in
fetchMessages { messages in
if messages.isEmpty {
continuation.resume(throwing: FetchError.noMessages)
} else {
continuation.resume(returning: messages)
}
}
}
} catch {
return [
Message(id: 1, from: "Tom", message: "Welcome to MySpace! I'm your new friend.")
]
}
}

let messages = await fetchMessages()
print("Downloaded \(messages.count) messages.")

存储 Continuations

通过将 Continuations 存储为属性,我们就可以在多个不同的地方恢复它。

下面以 LocationManager 为例,把 continuation 存储在属性中,位置更新成功或失败是在两个代理方法中,分别在这两个方法中恢复 continuation。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
var locationContinuation: CheckedContinuation<CLLocationCoordinate2D?, Error>?
let manager = CLLocationManager()

override init() {
super.init()
manager.delegate = self
}

func requestLocation() async throws -> CLLocationCoordinate2D? {
try await withCheckedThrowingContinuation { continuation in
locationContinuation = continuation
manager.requestLocation()
}
}

func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
locationContinuation?.resume(returning: locations.first?.coordinate)
}

func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
locationContinuation?.resume(throwing: error)
}
}

struct ContentView: View {
@StateObject private var locationManager = LocationManager()

var body: some View {
LocationButton {
Task {
if let location = try? await locationManager.requestLocation() {
print("Location: \(location)")
} else {
print("Location unknown.")
}
}
}
.frame(height: 44)
.foregroundColor(.white)
.clipShape(Capsule())
.padding()
}
}

在不支持并发的函数中进行异步调用

通过使用 Task 来解决问题。

1
2
3
4
5
6
7
8
9
10
11
func doAsyncWork() async {
print("Doing async work")
}

func doRegularWork() {
Task {
await doAsyncWork()
}
}

doRegularWork()

Sequence、AsyncSequence 和 AsyncStream

  • Sequence 协议,它不断返回值,直到通过返回 nil 终止序列。
  • AsyncSequence 协议几乎与 Sequence 相同,但序列中的每个元素都是异步返回的。
    • 从异步序列读取值必须使用 `await
  • 更高级的异步序列(AsyncStream)生成值的速度可能比您读取它们的速度快,在这种情况下,您可以丢弃额外的值或缓存它们以便稍后读取。`

使用 for wait 循环 AsyncSequence

URL lines: The URL’s resource data, as an asynchronous sequence of lines of text.

1
2
3
4
5
6
7
8
9
func fetchUsers() async throws {
let url = URL(string: "https://hws.dev/users.csv")!

for try await line in url.lines {
print("Received user: \(line)")
}
}

try? await fetchUsers()

使用异步序列可以有效地生成一个迭代器,然后重复调用它的 next() 直到它返回 nil ,此时循环结束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func printUsers() async throws {
let url = URL(string: "https://hws.dev/users.csv")!

var iterator = url.lines.makeAsyncIterator()

if let line = try await iterator.next() {
print("The first user is \(line)")
}

for i in 2 ... 5 {
if let line = try await iterator.next() {
print("User #\(i): \(line)")
}
}

var remainingResults = [String]()

while let result = try await iterator.next() {
remainingResults.append(result)
}

print("There were \(remainingResults.count) other users.")
}

try? await printUsers()

使用 map ()、filter () 等操作 AsyncSequence

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func getQuotes() async -> some AsyncSequence {
let url = URL(string: "https://hws.dev/quotes.txt")!
let anonymousQuotes = url.lines.filter { $0.contains("Anonymous") }
let topAnonymousQuotes = anonymousQuotes.prefix(5)
let shoutingTopAnonymousQuotes = topAnonymousQuotes.map(\.localizedUppercase)
return shoutingTopAnonymousQuotes
}

let result = await getQuotes()

do {
for try await quote in result {
print(quote)
}
} catch {
print("Error fetching quotes")
}

上面的代码,所有转换都创建了新的异步序列,因此我们不需要将它们与 await 一起使用,但许多转换也会生成单个值。这些必须使用 await 才能挂起,直到返回序列的所有部分,并且如果序列抛出,可能还需要使用 try。例如, allSatisfy(),检查异步序列中的所有元素是否都通过您选择的谓词。

1
2
3
4
5
6
7
func checkQuotes() async throws {
let url = URL(string: "https://hws.dev/quotes.txt")!
let noShortQuotes = try await url.lines.allSatisfy { $0.count > 30 }
print(noShortQuotes)
}

try? await checkQuotes()

其他类似的函数也是如此,例如 min()max()reduce()

创建自定义异步序列

创建 AsyncSequence

  • 需要遵守 AsyncSequenceAsyncIteratorProtocol 协议
  • 迭代器的 next() 方法必须标记为 async
  • 需要创建一个 makeAsyncIterator() 方法

下面是一个简单的每次调用 next() 时数字都会加倍的异步序列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct DoubleGenerator: AsyncSequence, AsyncIteratorProtocol {
typealias Element = Int
var current = 1

mutating func next() async -> Element? {
defer { current &*= 2 }

if current < 0 {
return nil
} else {
return current
}
}

func makeAsyncIterator() -> DoubleGenerator {
self
}
}

let sequence = DoubleGenerator()

for await number in sequence {
print(number)
}

另:在 Swift 编程语言中,&*= 是一个复合赋值运算符,用于执行带符号整数的溢出乘法操作。该运算符结合了溢出乘法运算符 &* 和赋值运算符 =。它可以确保在计算结果超出变量存储范围时,不会抛出溢出错误,而是保留溢出的结果。

1
2
3
var a: Int = Int.max
a &*= 2
print(a) // 输出为 -2

将 AsyncSequence 转换为 Sequence

最简单的方法是在序列上调用 reduce(into:)

1
2
3
4
5
extension AsyncSequence {
func collect() async rethrows -> [Element] {
try await reduce(into: [Element]()) { $0.append($1) }
}
}

Task

在 Swift 中使用 async/await 允许我们编写易于阅读和理解的异步代码,但它本身仍然会按顺序执行。为了创建实际的并发性(提供同时运行多个工作的能力),Swift 为我们提供了两种特定的类型: TaskTaskGroup

如果只是开始一两个独立的工作,那么 Task 是正确的选择。如果想将一项作业拆分为多个并发操作,那么 TaskGroup 更适合。

TaskTaskGroup 的优先级从高到低依次是 highmediumlowbackground。与 DispatchQueue 的 quality-of-service 相比,.high 等同于 .userInitiated.low 等同于 .utility

Task 的创建和运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
struct NewsItem: Decodable {
let id: Int
let title: String
let url: URL
}

struct HighScore: Decodable {
let name: String
let score: Int
}

func fetchUpdates() async {
let newsTask = Task { () -> [NewsItem] in
let url = URL(string: "https://hws.dev/headlines.json")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([NewsItem].self, from: data)
}

let highScoreTask = Task { () -> [HighScore] in
let url = URL(string: "https://hws.dev/scores.json")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([HighScore].self, from: data)
}

do {
let news = try await newsTask.value
let highScores = try await highScoreTask.value
print("Latest news loaded with \(news.count) items.")

if let topScore = highScores.first {
print("\(topScore.name) has the highest score with \(topScore.score), out of \(highScores.count) total results.")
}
} catch {
print("There was an error loading user data.")
}
}

await fetchUpdates()
  • Taxks 并不总是需要返回值,这里的返回值是 [NewsItem]
  • 一旦创建了任务,它就会开始运行;
  • 如果要读取任务的返回值,则需要使用 await 访问其 value 属性。

Detached Task

Task 会继承并运行在调用它的当前任务的执行环境和优先级下。它通常用于创建一个附属于当前上下文的任务,这样可以共享当前上下文的一些特性,例如:Actor 的隔离状态或结构化并发的范围。

Task.detached 会创建一个与当前上下文分离的独立任务。它不会继承创建它的上下文的优先级和任务状态,而是作为一个全新的任务来执行。通常在需要完全独立的并发执行时使用 Task.detached

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ViewModel: ObservableObject {}

struct ContentView: View {
@StateObject private var model = ViewModel()

var body: some View {
Button("Authenticate", action: doWork)
}

func doWork() {
Task {
for i in 1...10_000 {
print("In Task 1: \(i)")
}
}

Task {
for i in 1...10_000 {
print("In Task 2: \(i)")
}
}
}
}

上面的代码中的两个 Task,但它们也是按顺序执行的,因为 @StateObject 视图模型将整个视图强制到 main actor 上,这意味着它一次只能做一件事。这时,将 Task 更改为 Task.detached,这两个任务就可以同时运行。

从任务中获取结果

如果你想直接读取一个任务(Task)的返回值,应该使用 await 来读取其值,或者如果它包含抛出操作(throwing operation),则使用 try await。然而,所有任务也都有一个 result 属性,该属性返回一个 Swift 的 Result 结构实例,泛型化为任务返回的类型以及它是否可能包含错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
enum LoadError: Error {
case fetchFailed, decodeFailed
}

func fetchQuotes() async {
let downloadTask = Task { () -> String in
let url = URL(string: "https://hws.dev/quotes.txt")!
let data: Data

do {
(data, _) = try await URLSession.shared.data(from: url)
} catch {
throw LoadError.fetchFailed
}

if let string = String(data: data, encoding: .utf8) {
return string
} else {
throw LoadError.decodeFailed
}
}

let result = await downloadTask.result

do {
let string = try result.get()
print(string)
} catch LoadError.fetchFailed {
print("Unable to fetch the quotes.")
} catch LoadError.decodeFailed {
print("Unable to convert quotes to text.")
} catch {
print("Unknown error.")
}
}

await fetchQuotes()

任务优先级

创建一个具有优先级的任务:

1
2
3
Task(priority: .high) { () -> String in
// More code here
}

虽然您可以在创建任务时直接为其分配优先级,但如果不指定优先级,Swift 将遵循三个规则自动确定优先级:

  1. 如果该任务是从另一个任务创建的,则子任务将继承父任务的优先级。
  2. 如果新任务是直接从主线程而不是任务创建的,则会自动为其分配最高优先级 .userInitiated
  3. 如果新任务不是由另一个任务或主线程创建的,Swift 将尝试查询线程的优先级或为其赋予 nil 优先级。

任何任务都可以使用 Task.currentPriority 查询其当前优先级。

优先级升级

每个任务都可以创建为具有特定的优先级,也可以从其他地方继承优先级。但在两种特定情况下,Swift 会提高任务的优先级,以便能够更快地完成。

  1. 如果较高优先级任务 A 开始等待较低优先级任务 B 的结果,则任务 B 的优先级将提升到与任务 A 相同的优先级。
  2. 如果较低优先级的任务 A 已开始在某个 Actor 上运行,并且较高优先级的任务 B 已在该 Actor 上排队,则任务 A 的优先级将提升以匹配任务 B。

注意,第 2 种情况下,低优先级任务,其优先级会升级,但不会改变其 currentPriority 的值。

取消任务

虽然我们可以告诉任务停止工作,但任务本身可以完全忽略该指令并根据需要继续执行。

  1. 可以通过调用任务的 cancel() 方法显式取消任务。
  2. 可以检查 Task.isCancelled 来确定任务是否已被取消。
  3. 可以调用 Task.checkCancellation() 方法,如果任务已取消,该方法将抛出 CancellationError,否则不做任何操作。
  4. Foundation 的某些部分会自动检查任务取消情况,即使没有你的输入也会抛出它们自己的取消错误
  5. 如果你使用 Task.sleep() 来等待一段时间,取消你的任务将自动终止等待并抛出 CancellationError
  6. 如果任务是一个组的一部分,并且组的任何部分抛出错误,其他任务将被取消并等待。
  7. 如果你使用 SwiftUI 的 task() 修饰符启动了一个任务,该任务将在视图消失时自动取消。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func getAverageTemperature() async {
let fetchTask = Task { () -> Double in
let url = URL(string: "https://hws.dev/readings.json")!
let (data, _) = try await URLSession.shared.data(from: url)
try Task.checkCancellation()
let readings = try JSONDecoder().decode([Double].self, from: data)
let sum = readings.reduce(0, +)
return sum / Double(readings.count)
}

do {
let result = try await fetchTask.value
print("Average temperature: \(result)")
} catch {
print("Failed to get data.")
}
}

await getAverageTemperature()

URLSession.shared.data(from:) 存在隐式取消,其调用将在继续之前检查其任务是否仍处于活动状态。如果任务已被取消,data(from:) 将自动抛出 URLError 并且任务的其余部分将不会执行。
这里使用 Task.checkCancellation(),以在网络请求后显式检查取消。

下面的代码在任务创建后立即取消任务,并返回默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func getAverageTemperature() async {
let fetchTask = Task { () -> Double in
let url = URL(string: "https://hws.dev/readings.json")!

do {
let (data, _) = try await URLSession.shared.data(from: url)
if Task.isCancelled { return 0 }

let readings = try JSONDecoder().decode([Double].self, from: data)
let sum = readings.reduce(0, +)
return sum / Double(readings.count)
} catch {
return 0
}
}

fetchTask.cancel()

let result = await fetchTask.value
print("Average temperature: \(result)")
}

await getAverageTemperature()

现在我们有一个通过 data(from:) 调用的隐式取消点,以及一个通过 Task.isCancelled 检查的显式取消点。如果其中任何一个被触发,任务将返回 0 而不是抛出错误。

休眠任务

使当前任务休眠至少 3 秒:

1
try await Task.sleep(nanoseconds: 3_000_000_000)
  • 需要使用 await 调用 Task.sleep()
  • 需要使用 try ,因为 Task.sleep() 会自动检查取消,如果任务被取消,将会抛出一个 CancellationError 错误。

注意:调用 Task.sleep() 将使当前任务至少休眠您要求的时间,而不是您要求的确切时间。因为当睡眠结束时系统可能正忙于做其他工作。

另外,与使线程休眠不同,Task.sleep() 不会阻塞底层线程,因此在需要时它可以从其他地方获取工作。

主动暂停任务

可以调用 Task.yield() 来自动挂起当前任务。但调用 yield() 并不总是意味着任务会停止运行:如果它的优先级高于其他等待的任务,你的任务完全有可能立即恢复工作。将其视为一种指导——我们只是给 Swift 一个临时执行其他任务的机会,而不是强制它这样做。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func factors(for number: Int) async -> [Int] {
var result = [Int]()

for check in 1 ... number {
if number.isMultiple(of: check) {
result.append(check)
await Task.yield()
}
}

return result
}

let factors = await factors(for: 120)
print("Found \(factors.count) factors for 120.")

Task Group

创建任务组并向其中添加任务

并不是直接创建 TaskGroup 实例,而是通过调用 withTaskGroup(of:) 函数,并告诉它任务组将返回的数据类型。

下面是一个简单的示例,它返回 5 个常量字符串,将它们添加到一个数组中,然后将该数组连接到一个字符串中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func printMessage() async {
let string = await withTaskGroup(of: String.self) { group -> String in
group.addTask { "Hello" }
group.addTask { "From" }
group.addTask { "A" }
group.addTask { "Task" }
group.addTask { "Group" }

var collected = [String]()

for await value in group {
collected.append(value)
}

return collected.joined(separator: " ")
}

print(string)
}

await printMessage()
  1. 我们必须指定任务组将返回的数据的确切类型,在我们的例子中是 String.self ,以便每个子任务都可以返回一个字符串。
  2. 我们需要使用 group -> String in 准确指定组的返回值。
  3. 对于要添加到组中的每个任务,可以调用 addTask()
  4. 任务组符合 AsyncSequence ,因此我们可以使用 for await 或重复调用 group.next() 从其子级读取所有值。
  5. 因为整个任务组是异步执行的,所以我们必须使用 await 来调用它。
  6. 注意返回的结果是按完成顺序而不是创建的顺序。
  7. 使用 withTaskGroup() 创建的任务不能抛出错误。如果您希望它们能够抛出向上冒泡的错误(即在任务组之外处理的错误),您应该使用 withThrowingTaskGroup() 来代替。

无论您使用的是抛出任务还是非抛出任务,组中的所有任务都必须在组返回之前完成。您在这里有三个选择:

  1. 等待组中的所有任务完成。
  2. 调用 waitForAll() 将自动等待您未明确等待的任务,并丢弃它们返回的任何结果。
  3. 如果您没有显式等待任何子任务,它们将被隐式等待 - Swift 无论如何都会等待它们,即使您没有使用它们的返回值。

取消任务组

Swift 的任务组可以通过以下三种方式之一取消:

  1. 如果任务组的父任务被取消。
  2. 如果您在组上明确调用 cancelAll()
  3. 如果您的子任务之一引发未捕获的错误,则所有剩余任务将被隐式取消。

首先,调用 cancelAll() 将取消所有剩余的任务。与独立任务一样,取消任务组是合作性的:你的子任务可以使用 Task.isCancelledTask.checkCancellation() 来检查是否被取消,但如果它们愿意,也可以完全忽略取消操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func printMessage() async {
let result = await withThrowingTaskGroup(of: String.self) { group -> String in
group.addTask {
"Testing"
}
group.addTask {
"Group"
}
group.addTask {
"Cancellation"
}

group.cancelAll()
var collected = [String]()

do {
for try await value in group {
collected.append(value)
}
} catch {
print(error.localizedDescription)
}

return collected.joined(separator: " ")
}

print(result)
}

await printMessage()

上面的代码,在创建所有三个任务后立即调用 cancelAll(),但是当代码运行时,您仍然会看到所有三个字符串都打印出来。因为取消任务组是合作性的,所以除非你添加的任务隐式或显式地检查取消状态,否则单独调用 cancelAll() 并不会有太大作用。

尝试将第一个 addTask() 调用替换为:

1
2
3
4
group.addTask {
try Task.checkCancellation()
return "Testing"
}

会发现其结果还是不确定的,这是因为:Swift 将立即启动所有三个任务,它们可能全部并行运行,尽管我们立即调用 cancelAll(),但某些任务可能已经开始运行。

请记住,调用 cancelAll() 只会取消剩余的任务,这意味着它不会撤销已经完成的工作。即便如此,取消也是合作性的,因此你需要确保你添加到组中的任务会检查取消状态。

如何处理任务组中的不同结果类型

Swift 任务组中的每个任务必须返回与组中所有其他任务相同类型的数据,如果需要一个任务组来处理多种不同类型的数据,如果可以的话,您应该考虑使用 async let 来实现并发。

还有一个解决方案:创建一个具有关联值的枚举,该值包装您想要返回的基础数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
enum FetchResult {
case username(String)
case favorites(Set<Int>)
case messages([Message])
}

func loadUser() async {
let user = await withThrowingTaskGroup(of: FetchResult.self) { _ -> User in
// more code here
}
}

await loadUser()

SwiftUI 中的 task() 修饰符

SwiftUI 提供了一个 task() 修饰符,一旦视图出现,它就会启动一个新的分离任务,并在视图消失时自动取消该任务。

  1. 最简单的也是常用的,在 task() 中加载视图初始数据。
  2. 更高级的 task() 用法是附加某种 Equatable 的标识值 .task(id:) ——当该值发生变化时,SwiftUI 会自动取消之前的任务,并使用新值创建一个新任务。
  3. task() 的一个特别有趣的用例是与连续生成值的 AsyncSequence 集合一起使用。

Actors

Swift 的 actors 在概念上类似于类,但在并发环境中使用是安全的。这种安全性是因为 Swift 自动确保没有两段代码试图同时访问 actor 的数据——这是由编译器强制实现的,而不是要求开发人员编写使用锁等系统的样板代码。

  • Actors 是使用 actor 关键字创建的。
  • Actors 是引用类型。
  • Actors 具有许多与类相同的特性:你可以为它们定义属性、方法(异步或其他方法)、初始化器和下标,它们可以遵循协议,并且可以是泛型。
  • Actors 不支持继承,因此它们不能有便利初始化器,也不支持 finaloverride
  • 所有 actors 会自动遵循 Actor 协议,而其他类型不能使用该协议。这使你可以编写仅限于与 actor 一起工作的代码。

除此之外,actor 还有一个核心行为:如果你尝试读取 actor 的变量属性或调用其方法,并且是在 actor 外部进行的,那么你必须使用 await 以异步方式进行。

下面是一个简单的示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
actor User {
var score = 10

func printScore() {
print("My score is \(score)")
}

func copyScore(from other: User) async {
score = await other.score
}
}

let actor1 = User()
let actor2 = User()

await print(actor1.score)
await actor1.copyScore(from: actor2)

从 actor 外部写入属性是不允许的,无论是否使用 await

函数参数隔离

属于 actor 的任何属性和方法都是隔离到该 actor 的,但如果需要,你可以让外部函数也隔离到某个 actor。这样,该函数就可以像在该 actor 内部一样访问 actor 隔离的状态,而无需使用 await

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
actor DataStore {
var username = "Anonymous"
var friends = [String]()
var highScores = [Int]()
var favorites = Set<Int>()

init() {
// load data here
}

func save() {
// save data here
}
}

func debugLog(dataStore: isolated DataStore) {
print("Username: \(dataStore.username)")
print("Friends: \(dataStore.friends)")
print("High scores: \(dataStore.highScores)")
print("Favorites: \(dataStore.favorites)")
}

let data = DataStore()
await debugLog(dataStore: data)

函数签名中添加的 isolated 关键字,它允许直接访问 DataStore 的属性而不需要使用 await,整个函数必须在该 actor 上运行,因此需要使用 await 调用 debugLog(dataStore:)

部分隔离

默认情况下,Actor 内部的所有方法和可变属性都与该 Actor 隔离,可以使用 nonisolated 关键字将某些方法排除在外。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import CryptoKit
import Foundation

actor User {
let username: String
let password: String
var isOnline = false

init(username: String, password: String) {
self.username = username
self.password = password
}

nonisolated func passwordHash() -> String {
let passwordData = Data(password.utf8)
let hash = SHA256.hash(data: passwordData)
return hash.compactMap { String(format: "%02x", $0) }.joined()
}
}

let user = User(username: "twostraws", password: "s3kr1t")
print(user.passwordHash())
  • passwordHash() 标记为 nonisolated 意味着我们可以在外部调用它,而无需使用 await
  • 还可以将 nonisolated 与计算属性一起使用。
  • 非隔离属性和方法只能访问其他非隔离属性和方法。

使用 @MainActor 在主队列上运行代码

@MainActor 是一个全局 actor,它使用主队列来执行其工作。

例如,我们可以创建一个具有两个 @Published 属性的可观察对象,并且因为它们都会更新 UI,所以我们将用 @MainActor 标记整个类,以确保这些 UI 更新始终发生在 main actor:

1
2
3
4
5
@MainActor
class AccountViewModel: ObservableObject {
@Published var username = "Anonymous"
@Published var isAuthenticated = false
}

实际上,不需要显式地将 @MainActor 添加到可观察对象,因为 SwiftUI 视图的 body 属性始终在主 actor 上运行。如果您需要某些方法或计算属性来选择不在主 actor 上运行,可以使用 nonisolated

更广泛地说,任何包含 @MainActor 对象作为属性的类型也会隐式地被认为是 @MainActor,这是通过全局 actor 推断实现的。

@MainActor 会自动强制方法或整个类型在主 actor 上运行,大多数情况下无需我们做任何额外的工作。以前我们需要手动完成这项工作,记得在每个需要的地方使用诸如 DispatchQueue.main.async() 之类的代码,但现在编译器会自动为我们处理这一切。

MainActor.run() 方法是 Swift 中用于在主线程上执行代码的一种便捷方式。使用这个方法,你可以确保指定的代码块在主线程上运行,从而避免手动切换线程的麻烦。

  1. 简化线程切换:MainActor.run() 简化了将代码调度到主线程的过程,不需要手动调用 DispatchQueue.main.async()
  2. 支持异步代码:MainActor.run() 支持异步代码,你可以在其内部使用 await
  3. 保证主线程执行:使用 MainActor.run() 可以确保代码在主线程上执行,适用于需要在主线程上运行的 UI 更新等任务。
1
2
3
4
5
6
7
8
9
10
func couldBeAnywhere() async {
let result = await MainActor.run { () -> Int in
print("This is on the main actor.")
return 42
}

print(result)
}

await couldBeAnywhere()

也可以将任务的结束标记为 @MainActor

1
2
3
4
5
6
7
func couldBeAnywhere() {
Task { @MainActor in
print("This is on the main actor.")
}
}

couldBeAnywhere()

MainActor.run() 代码将立即执行 - 它不会像 DispatchQueue.main.async() 那样等到下一个运行循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@MainActor class ViewModel: ObservableObject {
func runTest() async {
print("1")

await MainActor.run {
print("2")

Task { @MainActor in
print("3")
}

print("4")
}

print("5")
}
}

MainActor.run() 的调用将在调用 runTest() 时立即运行。但是,内部的 Task 不会立即运行,因此代码将打印 1, 2, 4, 5, 3。

全局 actor 推断的工作原理

  1. 如果一个类被标记为 @MainActor ,那么它的所有子类也自动被标记为 @MainActor

  2. 如果类中的方法被标记为 @MainActor ,则该方法的任何重写也会自动标记为 @MainActor

  3. 任何使用 @MainActor 作为其包装值的属性包装器的结构或类将自动为 @MainActor

  4. 如果一个协议声明了一个方法是 @MainActor,那么任何遵循该协议的类型都会自动将该方法视为 @MainActor,除非你将协议的遵循与方法的实现分开。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    // A protocol with a single `@MainActor` method.
    protocol DataStoring {
    @MainActor func save()
    }

    // A struct that does not conform to the protocol.
    struct DataStore1 { }

    // When we make it conform and add save() at the same time, our method is implicitly @MainActor.
    extension DataStore1: DataStoring {
    func save() { } // This is automatically @MainActor.
    }

    // A struct that conforms to the protocol.
    struct DataStore2: DataStoring { }

    // If we later add the save() method, it will *not* be implicitly @MainActor so we need to mark it as such ourselves.
    extension DataStore2 {
    @MainActor func save() { }
    }
  5. 如果整个协议标记为 @MainActor,那么任何遵循该协议的类型在不显式分离协议遵循与主类型声明的情况下,也会自动成为 @MainActor;而如果你将协议的遵循与主类型声明分离开来,那么只有方法会被标记为 @MainActor

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    // A protocol marked as @MainActor.
    @MainActor protocol DataStoring {
    func save()
    }

    // A struct that conforms to DataStoring as part of its primary type definition.
    struct DataStore1: DataStoring { // This struct is automatically @MainActor.
    func save() { } // This method is automatically @MainActor.
    }

    // Another struct that conforms to DataStoring as part of its primary type definition.
    struct DataStore2: DataStoring { } // This struct is automatically @MainActor.

    // The method is provided in an extension, but it's the same as if it were in the primary type definition.
    extension DataStore2 {
    func save() { } // This method is automatically @MainActor.
    }

    // A third struct that does *not* conform to DataStoring in its primary type definition.
    struct DataStore3 { } // This struct is not @MainActor.

    // The conformance is added as an extension
    extension DataStore3: DataStoring {
    func save() { } // This method is automatically @MainActor.
    }

actor 跳跃

当一个线程暂停一个 actor 上的工作,转而开始在另一个 actor 上工作时,我们称之为 actor 跳跃(actor hopping)。这种情况会在一个 actor 调用另一个 actor 时发生。Actor hopping 的发生可以由编译器自动管理,确保并发操作的线程安全性和顺序执行,而无需开发人员显式管理线程切换。

但还是有一些注意事项:

  1. 性能影响:Actor 跳跃可能会引入额外的性能开销,因为线程需要在不同的 actor 之间切换。尽管 Swift 的并发模型尽可能地优化了这些切换过程,但频繁的 actor 跳跃仍可能影响到应用程序的响应性能。
  2. 线程安全:由于 Swift 的 Actor 模型确保了同一时间只有一个 actor 的代码可以执行,因此 actor 跳跃可以保证并发访问的线程安全性。然而,开发人员仍需注意避免可能导致竞态条件或数据不一致的操作。
  3. 异步操作:在执行 actor 跳跃时,可能涉及异步操作和等待。特别是当一个 actor 调用另一个 actor 的异步方法时,可能需要使用 await 来等待结果的返回,以确保异步操作的正确顺序。
  4. 代码设计:合理的代码设计可以减少 actor 跳跃的频率。尽量将相关的操作和数据封装在同一个 actor 内部,减少不同 actor 之间的交互,可以降低 actor 跳跃的发生频率,提升性能和可维护性。

下面是一个简单的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
actor NumberGenerator {
var lastNumber = 1

func getNext() -> Int {
defer { lastNumber += 1 }
return lastNumber
}

@MainActor func run() async {
for _ in 1 ... 100 {
let nextNumber = await getNext()
print("Loading \(nextNumber)")
}
}
}

let generator = NumberGenerator()
await generator.run()

在这段代码中,run() 方法必须在主 actor 上执行,因为它带有 @MainActor 属性,然而 getNext() 方法将在协作池(cooperative pool)的某个地方运行,这意味着 Swift 需要在循环内频繁地在主 actor 和协作池之间进行上下文切换。

actors, classes, and structs 之间的区别

Actors:

  • 是引用类型,适合用于共享可变状态。
  • 可以拥有属性、方法、初始化器和下标。
  • 不支持继承。
  • 自动遵循 Actor 协议。
  • 自动遵循 AnyObject 协议,因此可以在不添加显式 id 属性的情况下遵循 Identifiable 协议。
  • 可以有析构器。
  • 不能直接从外部访问其公共属性和方法;必须使用 await。
  • 只能同时执行一个方法,无论它们如何被访问。

Classes:

  • 是引用类型,适合用于共享可变状态。
  • 可以拥有属性、方法、初始化器和下标。
  • 支持继承。
  • 不能遵循 Actor 协议。
  • 自动遵循 AnyObject 协议,因此可以在不添加显式 id 属性的情况下遵循 Identifiable 协议。
  • 可以有析构器。
  • 可以直接从外部访问其公共属性和方法。
  • 可能同时执行多个方法。

Structs:

  • 是值类型,会被复制而不是共享。
  • 可以拥有属性、方法、初始化器和下标。
  • 不支持继承。
  • 不能遵循 Actor 协议。
  • 不能遵循 AnyObject 协议;如果要添加 Identifiable 协议的遵循,必须自己添加 id 属性。
  • 不能有析构器。
  • 可以直接从外部访问其公共属性和方法。
  • 可能同时执行多个方法。

另见