Swift学习笔记-并发编程

1,037 阅读14分钟

并发        

        Swift内置支持以结构化的方式编写异步和并行代码。异步代码可以挂起,稍后再继续执行,尽管一次只执行程序的一部分。异步执行的代码可以让程序在进行网络请求解析文件耗时操作的同时不阻塞非耗时任务如:页面的更新和用户的交互等需要快速响应的任务。并行代码意味着多任务同时执行——例如,一台拥有四核处理器的计算机可以同时运行四段代码,每个核心执行一项任务。一种使用并行和异步代码同时执行多个操作的程序,它挂起等待外部系统的操作(比如网络请求中服务端的返回),并使此类代码更加简便的以内存安全的方式来编写。

        并行或异步代码的额外调度灵活性也带来了复杂性增加的代价。Swift允许您以一种在编译时检查的方式来表达意图——例如,您可以使用actor来安全地访问可变状态。然而,为执行速度慢或漏洞百出的代码添加并发性并不能保证它会变得快速或正确。事实上,添加并发性甚至可能使代码更难调试。但是,在需要并发的代码中使用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
}

        对于异步和抛出的函数或方法,在抛出之前编写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使它们都是异步的,让应用程序的其余代码在等待图片准备就绪时继续运行。 为了理解上面例子的并发性质,下面是一种可能的执行顺序: 

  1. 代码从第一行开始运行,一直运行到第一行await。它调用listPhotos(inGallery:)函数,并在等待该函数返回时暂停执行。
  2. 当此代码的执行挂起时,同一程序中的其他一些并发代码就会运行。例如,可能一个长时间运行的后台任务继续更新一个新照片库列表。该代码也会运行到下一个挂起点(标记为await),或者直到它完成。 
  3. 在listPhotos(inGallery:)返回之后,这段代码将从此时开始继续执行。它将返回的值赋给photoNames。 
  4. 定义sortedNames和name的行是常规的同步代码。因为这些线上没有等待的标记,所以不可能有任何悬挂点。 
  5. 下一个await标记了对downloadPhoto(命名为:)函数的调用。这段代码再次暂停执行,直到该函数返回,给其他并发代码一个运行的机会。 
  6. downloadPhoto(named:)返回后,它的返回值被赋给photo,然后在调用show(_:)时作为参数传递。

        代码中标有await的可能挂起点表示当前代码段可能在等待异步函数或方法返回时暂停执行。这也被称为屈服线程,因为在幕后,Swift会暂停当前线程上的代码执行,并在该线程上运行其他代码。因为具有await的代码需要能够暂停执行,所以只有程序中的某些位置可以调用异步函数或方法

  • 异步函数、方法或属性体中的代码。
  • 用@main标记的结构、类或枚举的静态main()方法中的代码。
  • 独立子任务中的代码,如下面的非结构化并发所示:

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有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:)运行一次调用。每张照片都会在下一张开始下载之前完全下载。但是,这些操作无需等待——每张照片都可以独立下载,甚至可以同时下载。 要调用一个异步函数并让它与它周围的代码并行运行,async在let定义常量时写在前面,然后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以指示执行将暂停(如果需要),直到异步函数返回。

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

任务和任务组 

        一个任务是工作单元可以异步作为程序的一部分运行。所有异步代码都作为某些任务的一部分运行。上一节中描述的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) }
    }
}

非结构化并发 

        除了前面部分描述的结构化并发方法之外,Swift 还支持非结构化并发。与属于任务组的任务不同,非结构化任务没有父任务。您可以完全灵活地以任何程序需要的方式管理非结构化任务,但您也对它们的正确性完全负责。要创建在当前 actor 上运行的非结构化任务,请调用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

任务取消

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

  • 抛出一个错误,如 CancellationError
  • 返回nil或空集合 
  • 退回部分完成的工作 

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

要手动传播取消,请调用Task.cancel()。

Actor

        和类一样,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 一次只允许一个任务访问其可变状态,如果来自另一个任务的代码已经与记录器交互,则该代码在等待访问该属性时暂停。 

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

extension TemperatureLogger {
    func update(with measurement: Int) {
        measurements.append(measurement)
        if measurement > max {
            max = measurement
        }
    }
}

        该update(with:)方法已经在 actor 上运行,所以它不会像maxwith那样标记它对属性的访问await。此方法还显示了 Actor 一次只允许一项任务与其可变状态交互的原因之一:对 Actor 状态的某些更新会暂时破坏不变量。该TemperatureLogger演员跟踪下的列表和最高温度,并在录制新的测量它更新的最高温度。在更新过程中,在追加新测量值之后但在更新之前max,温度记录器处于临时不一致状态。防止多个任务同时与同一个实例交互可以防止出现类似以下事件序列的问题:

  1. 您的代码调用该 update(with:)方法。它首先更新measurements数组。

  2. 在您的代码可以更新之前max,其他地方的代码读取最大值和温度数组。

  3. 您的代码通过更改max

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

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

print(logger.max)  // Error

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