在首次挂起前将计算任务存入 actor,然后让并发调用方等待同一个任务。本指南为 Mac 实用工具构建一个本地平方和示例,并明确规定取消、错误和清理的处理方式。

并发调用方应该共享什么?

假设 Mac 桌面实用工具的两个部分需要同一个计算摘要:预览面板和详情面板。它们都要计算合成数组 [1, 2, 3, 4] 中各元素的平方和。两者都应得到 30,加入同一轮活动计算的请求应共享一次执行。

下面的实现为每个计算器实例提供一个不可变的输入数组。当任务占据 actor 的执行槽时,共享当前任务;调用方观察到任务完成后,再清空该槽。后续请求可以启动另一轮计算。这种方式共享的是正在进行的工作;若要无限期保留已完成的结果,则需要单独制定缓存策略。

这里的值契约刻意保持精简:

  • 最多接受 100,000 个整数,每个整数介于 0 和 1,000 之间。
  • 返回这些整数的平方和;空数组返回 0。
  • 附加一个运行标识符,让调用方能够区分共享执行和仅仅总和相等的情况。
  • 将计算错误传递给等待该轮计算的调用方。
  • 允许单个调用方取消自己的等待,而不取消共享工作任务。

允许的最大总和是 100,000 × 1,000² = 100,000,000,000,可由 Mac 的 64 位 Int 表示。这些限制属于本示例的契约。更改限制时,需要重新考虑算术溢出和资源使用。

为什么 actor 仍然需要存储任务?

actor 会保护其可变状态,但异步 actor 方法可能会挂起。挂起期间,另一个调用可以进入该 actor。Swift 语言指南说明了 actor 隔离和挂起点;Apple 的 actor 专题也演示了在 await 之前作出的假设如何变得过时。Concurrency — The Swift Programming Language,Protect mutable state with Swift actors。

因此,检查是否已有结果,然后等待一次全新计算,并不能防止重复工作。结果产生前,另一个调用方可能到达。正确做法是同步发布任务句柄:检查执行槽;仅当槽为空时才创建工作任务并将其存入槽;然后等待任务结果。

Apple 文档说明,任务创建后可能立即开始。访问其异步 value 会等待任务完成,并传递抛出的错误。因此,actor 负责协调一个句柄的归属;调用方通过该句柄获取结果。Task,value。

构建完整的本地示例

请在 macOS 13 或更高版本上使用 Swift 6 编译器。这里要求 macOS,是因为演示中调用了 Task.sleep(for:)。该调用会挂起,但不会阻塞底层线程;短暂暂停让并发请求更容易观察。它不参与任何算术运算,之后可以移除。sleep(for:tolerance:clock:)。

将以下完整程序保存为 SharedCalculation.swift。它使用普通的非隔离默认设置进行下方所示的命令行编译。Foundation 提供 UUID,其文档列出的遵循协议包括 Sendable 和 Equatable;该标识符用于标记每轮计算,并支持有条件地清理执行槽。UUID。

import Foundation

struct CalculationResult: Sendable {
    let total: Int
    let runID: UUID
}

enum CalculationError: Error {
    case invalidInput
}

actor SharedCalculation {
    private struct Flight {
        let id: UUID
        let task: Task<CalculationResult, Error>
    }

    private let samples: [Int]
    private var flight: Flight?

    init(samples: [Int]) {
        self.samples = samples
    }

    func value() async throws -> CalculationResult {
        try Task.checkCancellation()

        let active: Flight
        if let existing = flight {
            active = existing
        } else {
            let id = UUID()
            let snapshot = samples
            let task = Task.detached(priority: .userInitiated) {
                [snapshot, id] () async throws -> CalculationResult in
                guard snapshot.count <= 100_000,
                      snapshot.allSatisfy({ (0...1_000).contains($0) })
                else {
                    throw CalculationError.invalidInput
                }

                // Demonstration only: allow overlap to be observed.
                try await Task.sleep(for: .milliseconds(100))
                let total = snapshot.reduce(0) { sum, sample in
                    sum + sample * sample
                }
                return CalculationResult(total: total, runID: id)
            }
            active = Flight(id: id, task: task)
            flight = active
        }

        defer {
            // An older waiter must not clear a newer flight.
            if flight?.id == active.id {
                flight = nil
            }
        }

        let result = try await active.task.value
        try Task.checkCancellation()
        return result
    }
}

@main
struct Demo {
    static func main() async throws {
        let calculation = SharedCalculation(samples: [1, 2, 3, 4])

        async let preview = calculation.value()
        async let details = calculation.value()
        async let inspector = calculation.value()

        let (a, b, c) = try await (preview, details, inspector)
        precondition(a.total == 30 && b.total == 30 && c.total == 30)

        let observedRuns = Set([a.runID, b.runID, c.runID]).count
        print("Total: \(a.total)")
        print("Runs observed across three requests: \(observedRuns)")

        let later = try await calculation.value()
        precondition(later.total == 30)
        print("Later request started another run: \(later.runID != c.runID)")
    }
}

在包含该文件的目录中编译并运行:

swiftc -swift-version 6 -parse-as-library SharedCalculation.swift -o SharedCalculation
./SharedCalculation

算术断言应当通过。如果最初的三个调用都在执行槽清空前进入,观察到的运行次数为 1。后续请求是在这些调用返回后才到达,因此会启动另一轮计算。暂停会促使调用重叠,但无法保证调度结果:若某个初始请求延迟足够久,启动后续一轮也是合理的。研究环境中无法使用 Swift,因此这里只检查了程序,没有编译或运行。

工作任务使用脱离上下文的任务,因为这项计算属于共享计算器,而非某个请求它的调用方;其算术运算也应独立于负责协调的 actor。它只捕获输入快照和标识符。Apple 建议在结构化子任务能够表达所需生命周期时使用它们;脱离上下文的任务需要明确决定生命周期和取消方式。本示例也显式指定了优先级。detached(name:priority:operation:)。

清理如何避免清除下一轮计算?

检查并存入任务的过程中没有 await,因此第二个 actor 调用无法在检查空槽和发布新一轮计算之间插入。第一个调用方在 active.task.value 上挂起后,另一个调用方可以进入,找到该轮计算,并等待同一个句柄。

清理也需要检查标识符,因为多个调用方可能在同一个已完成任务上先后恢复。请看下面的过程:

事件 执行槽
调用方 A 和 B 加入运行 X X
X 完成;A 恢复并清除 X 空
调用方 C 进入并启动运行 Y Y
B 从 X 恢复并执行清理 Y 必须保留

如果 B 无条件将 nil 赋给执行槽,就会抹掉 Y 的句柄。此时第四个调用方可能在 Y 仍运行时启动重复工作。将 flight?.id 与 active.id 比较,可以确保清理只针对调用方实际加入的那轮计算。这个过程是该实现和文档所述可重入行为的结果。Protect mutable state with Swift actors。

共享的确切边界是已存储的执行槽。工作任务可能已经完成,而等待它的调用方还没有恢复并清空执行槽。在此间隔中进入的请求仍可能获得已完成任务的结果。执行槽清空后,新请求会启动新一轮计算。这段短暂的完成间隔属于契约的一部分。

调用方取消或计算失败时会怎样?

Swift 中的取消是协作式的:代码必须决定在哪里检查取消,以及如何处理。本示例在加入任务前检查请求方的任务,并在成功收到共享结果后再次检查。Task — Task Cancellation。

在第一次检查时已经取消的调用方会直接抛出错误,不会启动或加入工作任务。等待期间取消的调用方仍会继续等待共享工作任务;若工作任务成功返回,第二次检查会抛出 CancellationError。其他调用方仍可收到结果,因为此实现不会将单个调用方的取消传给工作任务。

这种策略不能让等待立即取消。如果所有调用方都取消,工作任务仍会运行至完成。这是针对这一有界本地计算的有意选择。若设计需要单个调用方立即退出,则还需要记录等待者,并明确规定何时应停止共享工作任务。

无效输入会使工作任务抛出 CalculationError.invalidInput。已经共享该任务的调用方会通过 value 收到错误,其延迟清理会释放匹配的执行槽。之后的请求会针对同一个不可变输入重试,并再次失败。实现不会悄悄替换总和或丢弃无效元素。value。

还有一个明确的错误顺序细节:如果调用方在工作任务失败时取消,工作任务的错误会先传递,然后才进行第二次取消检查。如果工作任务成功,调用方的取消检查则决定该调用方是否返回结果。

在 Mac 实用工具中应如何验证和使用此模式?

总和相等本身不能证明执行是共享的:对该输入分别计算三次,也会得到 30。检查并发时应比较运行标识符,并单独验证数值结果。

要进行确定性的并发测试,请用可控的异步闸门替换演示中的暂停,并记录加入执行任务的情况。将工作任务阻塞到所有预期调用方都已加入,然后再放行。检查工作任务只启动一次、标识符相同、总和正确、错误能够传递,以及清理后会启动新一轮计算。还要测试表格中的清理过程。单纯延长休眠时间无法可靠地确保这些条件。

在桌面应用中,对于使用相同输入的调用方,应共用一个计算器实例。若为每个面板都创建新实例,每个面板就会有各自独立的执行槽。合成输入变化时,应为该快照创建新的计算器;本示例没有定义可变输入或按键共享。

在主 actor 上将返回值应用到 UI 状态。Swift 语言指南说明,UI 工作可以使用 @MainActor;共享计算器可以继续单独协调自身状态。Concurrency — The Swift Programming Language。

对于简单的求和任务,创建任务会产生不必要的开销;本示例旨在清楚展示归属规则,以便应用到更复杂的本地计算中。改造时请保留核心顺序:在挂起前发布一个任务,等待该句柄,并且只清除已完成的那轮计算。

来源