对于必须按提交顺序完成本地异步计算的 Mac 应用,请使用同步的主 Actor 提交方法,将每个任务链接在其前任之后。仅靠 Actor 隔离无法提供这种排序保证。

本演示构建了一个用于合成整数计算的小型队列。它准确定义了提交发生的时机,在挂起点之间保持顺序,返回每次计算的结果,并允许后续作业在前一个作业失败后继续执行。

什么是严格提交顺序?

假设一个桌面计算器提交了作业 A、B 和 C。所需的契约是:A 的计算在 B 的计算开始前完成,B 在 C 开始前完成。按字母顺序等待三个独立启动的任务只会对结果的观察顺序进行排序。

提交边界很重要。在此实现中,提交发生在主 Actor 隔离的 submit 方法执行并安装链中的下一个环节时。来自同一主 Actor 上下文的连续同步调用可在不挂起的情况下建立 A、B、C 的顺序。

如果独立任务并发请求提交,它们的到达顺序是未指定的。队列保留其提交方法接受它们的顺序。如果您的应用程序要求这些生产者之间有特定顺序,请在提交前建立该顺序——例如,将有限批次收集到一个显式有序数组中。

该设计将主 Actor 用于简短的簿记步骤。Apple 将 MainActor 定义为单例 Actor,其执行器等同于主调度队列。下面的实际计算属于单独的工作 Actor。MainActor 文档

为什么 Actor 或串行操作队列不够用?

Actor 会逐个执行 Actor 隔离的工作,但异步方法可以挂起并允许另一个调用继续执行。Swift 的 Actor 设计还明确指出,等待 Actor 的任务不保证按照它们最初等待的顺序运行。因此,将整个异步计算放入 Actor 本身并不能建立完成前下一个开始的行为。SE-0306: Actors

创建单独的 Task 实例也不会在它们之间提供依赖关系。任务创建后即可开始运行;保留其句柄可让您等待或取消它们,但不会建立执行序列。Task 文档

Foundation 提供了一种不同的排序机制。OperationQueue 根据就绪状态、优先级和依赖关系进行调度;Apple 建议在特定执行顺序很重要时使用依赖关系。将 maxConcurrentOperationCount 设置为 1 可限制并发,但排序要求仍需显式依赖。OperationQueue 文档

根据应用程序已有的需求选择机制:

场景 适用方法 什么建立顺序?
在一个异步函数中有一个已知批次 循环等待每个计算完成后再继续 顺序控制流
作业随时间从桌面操作到达 具有任务链的同步提交方法 每个计算等待其前任的完成门控
现有 Foundation 操作 入队前配置的依赖关系 每个操作依赖于前一个操作的完成状态

任务链在此很有用,因为提交会立即返回一个句柄,而计算稍后运行。对于没有新增作业的固定批次,顺序循环通常代码更少。

构建一个等待完整异步计算的队列

队列存储一个私有的、非抛出的完成任务,称为 tail。每次提交捕获当前尾部,创建一个等待它的计算任务,并用一个观察计算完成的新任务替换尾部。

完成任务等待 result,它表示任务完成后的成功或失败。这允许不成功的计算释放下一个作业,而不丢弃计算自身句柄中的错误。调用者仍可通过句柄检索该错误。Task result 文档

以下是针对配备 Swift 6 工具链的 Mac 的完整命令行演示。将其保存为 OrderedDemo.swift。源代码已检查,但无法在本研究环境中编译,因为没有可用的 Swift 编译器。

import Foundation

@MainActor
final class OrderedIntQueue {
    private var tail: Task<Void, Never>?

    func submit(
        _ operation: @escaping @Sendable () async throws -> Int
    ) -> Task<Int, Error> {
        let predecessor = tail

        let job = Task<Int, Error> {
            if let predecessor {
                await predecessor.value
            }
            try Task.checkCancellation()
            return try await operation()
        }

        tail = Task<Void, Never> {
            _ = await job.result
        }

        return job
    }
}

enum CalculationError: Error {
    case inputOutOfRange
}

actor CalculationWorker {
    private var events: [String] = []

    func square(_ input: Int, label: String) async throws -> Int {
        events.append("start \(label)")
        guard (-1000...1000).contains(input) else {
            throw CalculationError.inputOutOfRange
        }

        await Task.yield()
        try Task.checkCancellation()

        let answer = input * input
        events.append("finish \(label)")
        return answer
    }

    func trace() -> [String] {
        events
    }
}

@main
struct OrderedDemo {
    @MainActor
    static func main() async {
        let queue = OrderedIntQueue()
        let worker = CalculationWorker()

        let a = queue.submit {
            try await worker.square(3, label: "A")
        }
        let b = queue.submit {
            try await worker.square(4, label: "B")
        }
        let c = queue.submit {
            try await worker.square(5, label: "C")
        }

        var answers: [Int] = []
        for job in [a, b, c] {
            switch await job.result {
            case .success(let answer):
                answers.append(answer)
            case .failure(let error):
                print("Calculation failed: \(error)")
            }
        }

        let events = await worker.trace()
        precondition(answers == [9, 16, 25])
        precondition(events == [
            "start A", "finish A",
            "start B", "finish B",
            "start C", "finish C"
        ])

        print(events.joined(separator: "\n"))
        print("Answers: \(answers)")
    }
}

从其目录中的终端编译并运行保存的程序:

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

预期输出根据示例的输入和依赖链如下:

start A
finish A
start B
finish B
start C
finish C
Answers: [9, 16, 25]

断言同时检查返回值和计算边界。仅在提交顺序中等待句柄并打印结果无法确定计算按该顺序运行。

工作器刻意调用 Task.yield(),以便计算包含一个挂起的机会。让步允许其他任务执行,尽管执行器可能立即恢复同一任务。队列的排序因此来自其依赖关系,而非假设让步会切换任务。Task yield 文档

为什么链能在挂起时保持顺序?

三个细节确立了保证。首先,submit 不包含挂起点,因此另一个主 Actor 提交无法在其捕获和替换 tail 时插入。Swift 文档指出,没有挂起点的 Actor 隔离代码会在没有另一个 Actor 任务中断该序列的情况下运行。并发:Actors

其次,每个作业在调用计算闭包前等待其捕获的前任。访问非抛出任务的 value 会等待该任务完成。后续作业可以提前执行其等待簿记,但不能提前开始计算。非抛出 Task value 文档

第三,前任门控仅在前一个作业产生其结果或错误后完成。向后追踪链接为每个被接受的提交建立了序列:C 等待 B 的完成门控,后者等待 B,后者等待 A 的完成门控。

这是针对每个提交闭包内被等待工作的排序保证。如果闭包启动另一个非结构化任务并返回而不等待它,该额外工作可能比闭包活得更久。在返回前将每个必需的计算步骤包含在被等待的操作中。

对于桌面集成,在处理相关操作的主 Actor 控制器或模型中保留一个队列实例。直接从该上下文调用 submit,然后使用返回的句柄观察完成。先创建一个独立任务并在其开始后才提交,会将提交边界移至该任务的最终执行。

错误和取消应如何表现?

此实现遵循失败后继续策略。失败的作业保留其错误,其完成门控完成,下一个被接受的作业有资格进行计算。演示检查每个句柄的 result,而不是在第一个错误后放弃剩余结果。

对于简单的失败检查,将 B 的输入从 4 改为 1001。工作器在乘法前拒绝它。将预期答案更新为 [9, 25],预期追踪更新为 start A、finish A、start B、start C、finish C。程序应打印失败并仍计算 C。输入边界是为保持算术较小而选择的说明性契约。

取消是协作的。队列在等待前任后检查取消,工作器在让步后再次检查。当请求取消时,Task.checkCancellation() 抛出 CancellationError。Task checkCancellation 文档

要在执行前检查取消,在提交 C 后立即且在结果循环前调用 b.cancel()。在这些不间断的主 Actor 提交中,B 尚未进入其计算。将预期答案调整为 [9, 25],并从预期追踪中移除两个 B 条目。B 仍在报告取消前等待 A 的完成门控;C 然后继续。

保持完成门控私有。它们代表链的簿记,而返回的句柄代表单个计算。取消作业并不意味着前面的计算应停止,或后面的计算应在其仍在运行时开始。

如果您的应用程序要求在一个失败后停止整个批次,那需要显式的批次策略。将门控改为传播错误会改变哪些后续作业执行以及如何报告其结果。

何时应使用 Foundation 依赖?

如果桌面应用程序已将工作表示为 Foundation Operation 对象,请在入队前建立从每个操作到其前任的依赖关系。Foundation 认为当前任完成时依赖关系即被满足,无论成功或失败;成功依赖行为需要单独的结果处理。Operation 文档

将 Swift 异步函数桥接到该模型时要小心。启动 Task 并立即返回的同步操作不代表任务的完整生命周期。自定义异步操作必须准确管理其执行和完成状态,包括所需的通知,以便依赖操作等待预期的工作。Apple 在异步操作和完成状态报告下记录了这些职责。Operation 生命周期文档

对于此处开发的本地异步计算器,任务链使契约可见:同步接受提交,等待前任完成,运行完整计算,并暴露其结果。将队列连接到桌面操作时保留这些边界,并通过开始和完成追踪以及返回值验证执行。

来源