Swift 5.x - 并发(中文文档)

引言

继续学习Swift文档,从上一章节:错误处理,我们学习了Swift错误处理相关的内容,主要有使用throwing函数,throw抛出错误、使用do-catch来处理错误、将错误转为可选值(使用try?)、禁用错误传递(使用try!)、延迟操作处理(使用defer关键词)等这些内容。现在,我们学习Swift并发的相关内容。由于篇幅较长,这里分篇来记录,接下来,Fighting!

Swift 内置支持以结构化方式编写异步和并行代码。 尽管一次只执行一段程序,异步代码可以挂起并稍后恢复运行。 挂起和恢复程序中的代码可以让它在短时间内继续操作,例如更新其 UI,同时继续处理长时间运行的操作,例如通过网络获取数据或解析文件。 并行代码意味着多段代码同时运行——例如,具有四核处理器的计算机可以同时运行四段代码,每个核执行一项任务。 使用并行和异步代码同时执行多个操作的程序; 它可以将正在等待外部系统的操作挂起,并可以更容易地以内存安全的方式编写此代码。

并行或异步代码带来的额外的灵活性调度也伴随着复杂性增加的代价。 Swift 允许您以某种方式表达您的意图,从而启用一些编译时检查——例如,您可以使用 actor 来安全地访问可变状态。 但是,向缓慢或有缺陷的代码添加并发并不能保证它会变得快速或正确。 事实上,添加并发甚至可能会使您的代码更难调试。 但是,在需要并发的代码中使用支持并发的Swift语言,可以帮助您在编译时发现问题。

本章的其余部分使用并发术语来指代异步和并行代码的这种常见组合。

注意
如果您之前编写过并发代码,您可能习惯于使用线程。 Swift 中的并发模型建立在线程之上,但您不直接与它们交互。 Swift 中的异步函数可以放弃它正在运行的线程,这让另一个异步函数在该线程上运行而第一个函数被阻塞。

尽管可以在不使用 Swift 语言的情况下编写并发代码,但该代码往往更难阅读。 例如,以下代码下载照片名称列表,下载该列表中的第一张照片,并向用户显示该照片:

listPhotos(inGallery: "Summer Vacation") { photoNames in
    let sortedNames = photoNames.sorted()
    let name = sortedNames[1]
    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[1]
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() 方法中的代码。
  • 分离的子任务中的代码,如下面的Unstructured Concurrency
    中所示。

注意
Task.sleep(_:) 方法在编写简单代码以了解并发工作原理时很有用。 此方法什么都不做,但在返回之前至少等待给定的纳秒数。 这是 listPhotos(inGallery:) 函数的一个版本,它使用 sleep() 来模拟等待网络操作:

func listPhotos(inGallery name: String) async -> [String] {
    await Task.sleep(2 * 1_000_000_000)  // Two seconds
    return ["IMG001", "IMG99", "IMG0404"]
}

异步队列

上一节中的 listPhotos(inGallery:) 函数在数组的所有元素都准备就绪后一次性异步返回整个数组。 另一种方法是使用异步队列一次等待集合的一个元素。 以下是对异步队列进行迭代的样子:

import Foundation

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

上面的例子没有使用普通的 for-in 循环,而是在它后面写了 for with await。 就像调用异步函数或方法时一样,写 await 表示可能的暂停点。 for-await-in 循环可能会在每次迭代开始时暂停执行,等待下一个可用的元素。

就像您可以通过添加对 Sequence 协议的一致性在 for-in 循环中使用自己的类型一样,您可以通过添加对 AsyncSequence 协议的一致性在 for-await-in 循环中使用自己的类型。

并行调用异步函数

使用 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,因为代码不会挂起以等待函数的结果。 相反,执行会一直持续到定义照片的那一行——在这一点上,程序需要这些异步调用的结果,所以可以编写 await 来暂停执行,直到所有三张照片都完成下载。

以下是您如何思考这两种方法之间的差异:

  • 当以下行中的代码取决于该函数的结果时,使用 await 调用异步函数。 这将创建按顺序执行的工作。
  • 当您在代码中稍后才需要结果时,请使用 async-let 调用异步函数。 这将创建可以并行执行的工作。
  • await 和 async-let 都允许其他代码在挂起时运行。
  • 在这两种情况下,您都用 await 标记可能的暂停点以指示执行将暂停(如果需要),直到异步函数返回。

您还可以在同一代码中混合使用这两种方法。

任务和任务组

任务是一个工作单元,可以作为程序的一部分异步运行。 所有异步代码都作为某些任务的一部分运行。 上一节中描述的 async-let 语法为您创建了一个子任务。 您还可以创建一个任务组并将子任务添加到该组中,这使您可以更好地控制优先级和取消操作,并让您创建动态数量的任务。

任务按层次结构排列。 任务组中的每个任务都有相同的父任务,每个任务可以有子任务。 由于任务和任务组之间存在显式关系,因此这种方法称为结构化并发。 尽管在代码的正确性无法保证,但任务之间显式的父子关系让 Swift可以 处理一些行为,例如为您进行取消操作,并让 Swift 在编译时检测到一些错误。

await withTaskGroup(of: Data.self) { taskGroup in
    let photoNames = await listPhotos(inGallery: "Summer Vacation")
    for name in photoNames {
        taskGroup.async { await downloadPhoto(named: name) }
    }
}

有关任务组更多的信息,请移步TaskGroup

非结构化并发

除了前面几节中描述的结构化并发方法之外,Swift 还支持非结构化并发。 与属于任务组的任务不同,非结构化任务没有父任务。 您可以完全灵活地以任何程序需要的方式管理非结构化任务,但您也对它们的正确性完全负责。 要创建在当前 actor 上运行的非结构化任务,请调用 async(priority:operation:) 函数。 要创建不属于当前参与者的非结构化任务,更具体地说,称为分离任务,请调用 asyncDetached(priority:operation:)。 这两个函数都返回一个任务操作,让您可以与任务进行交互——例如,等待其结果或取消它。

let newPhoto = // ... some photo data ...
let handle = async {
    return await add(newPhoto, toGalleryNamed: "Spring Adventures")
}
let result = await handle.get()

有关管理分离任务的更多信息,请参阅Task.Handle

任务取消

Swift 并发使用协作取消模型。 每个任务检查它是否在其执行的适当点被取消,并以任何适当的方式响应取消。 根据您所做的工作,这通常意味着以下情况之一:

  • 像CancellationError一样抛出错误
  • 返回nil或者空的集合
  • 退回部分完成的工作

要检查取消,请调用 Task.checkCancellation()
,如果任务被取消,它会抛出 CancellationError,或者检查 Task.isCancelled
的值并在您自己的代码中处理取消。 例如,从图库下载照片的任务可能需要删除部分下载并关闭网络连接。

要手动取消,请调用Task.Handle.cancel()

Actors

和类一样,actor 也是引用类型,所以 Classes Are Reference Types 中值类型和引用类型的比较既适用于 actor,也适用于类。 与类不同,actor 一次只允许一个任务访问其可变状态,这使得多个任务中的代码可以安全地与同一个 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 关键字引入一个 actor类,然后用一对大括号来定义它。 TemperatureLogger 角色具有角色外部的其他代码可以访问的属性,并限制了 max 属性,因此只有角色内部的代码才能更新最大值。

您可以使用与结构体和类相同的初始化器语法来创建 actor 的实例。 当你访问一个 actor 的属性或方法时,你使用 await 来标记潜在的暂停点——例如:

let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)
// Prints "25"

在这个例子中,访问 logger.max 是一个挂起的地方。 因为 actor 一次只允许一个任务访问其可变状态,如果来自另一个任务的代码已经与logger交互,则该代码在等待访问该属性时挂起。

相比之下,actor 的一部分代码在访问 actor 的属性时不会编写 await。 例如,这是一个使用新温度更新 TemperatureLogger 的方法:

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 之前,温度记录器处于临时不一致状态。 防止多个任务同时与同一个实例交互可以防止出现类似以下事件队列的问题:

  • 您的代码调用 update(with:) 方法。 它首先更新测量数组。
  • 在您的代码可以更新 max 之前,其他地方的代码会读取最大值和温度数组。
  • 您的代码通过更改 max 来完成更新。

在这种情况下,在别处运行的代码会读取不正确的信息,因为它对 actor 的访问在调用 update(with:) 的过程中交错进行,而数据暂时是无效的。 您可以在使用 Swift actor 时防止出现此问题,因为它们一次只允许对其状态进行一次操作,并且因为该代码只能在 await 标记暂停点的地方中断。 因为 update(with:) 不包含任何暂停点,所以没有其他代码可以在更新过程中访问数据。

如果您尝试从 actor 外部访问这些属性,就像使用类的实例一样,您将收到编译时错误; 例如:

print(logger.max)  // Error

在不写入 await 的情况下访问 logger.max 会失败,因为 actor 的属性是该 actor 隔离的本地状态的一部分。 Swift 保证只有 Actor 内部的代码才能访问 Actor 的本地状态。 这种保证称为参与者隔离。

总结

Swift支持使用异步和并行代码来处理耗时操作,就像OC中使用GCD或NSOperation编写多线程一样;相比之下,Swift语法要简单的多。

  • 使用async关键字来实现异步操作,写法有两种,如下:
func listPhotos(inGallery name: String) async -> [String] {
    let result = // ... some asynchronous networking code ...
    return result
}
async let firstPhoto = downloadPhoto(named: photoNames[0])
  • 使用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)
  • asyncawait配合使用实现多线程操作,并行调用方法,等待任务全部完成后,再执行之后的任务,如:
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)
  • 这里简单的说了一下任务和任务组的概念,将任务加入到任务组,可以方便的控制任务优先级和进行取消操作,创建动态的任务数量,更多详细的内容,请看TaskGroup
  • 任务处理的相关操作,如任务取消,请参阅Task.Handle
  • 使用actor关键字来避免数据竞争,相当于OC中的锁。

并发的内容就这些了,最后的最后,以上总结若有错误,请指正!喜欢的朋友也麻烦您点个赞哟~

上一章节:错误处理

参考文档:[Swift - Concurrency](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html

你可能感兴趣的:(Swift 5.x - 并发(中文文档))