使用一个长期存在的 Foundation OperationQueue,并在提交计算前设置其 maxConcurrentOperationCount。对于每次按钮按下都会接收一个请求的 Mac 实用工具,这样既能限制同时执行的工作,也能让后续请求排队等待。

本教程为这项桌面任务构建计算引擎,并提供一个独立程序来检查其行为。每个请求都包含合成整数,每个结果都保留在内存中。计算会在其操作中同步运行,因此操作的生命周期涵盖实际工作。

哪种并发工具适合逐个到来的请求?

OperationQueue 可以逐个接收操作,而其基于闭包的 addOperation(_:) 方法会将每个提交的闭包封装为一个操作。将队列保存在一个跨请求持续存在的对象中,然后为每个传入项提交一个闭包。Apple 的 addOperation(_:) 文档介绍了此提交方法。

将 maxConcurrentOperationCount 设为正数上限,例如 2。Apple 将此属性定义为该队列中可同时执行的操作的最大数量。此限制只适用于特定队列;若每个请求都创建一个单独的队列,共享限制就会失效。之后调低限制也不会停止已经在执行的操作。Apple 的并发限制文档说明了这些边界。

Swift 任务组的生命周期用途不同。它必须位于 withTaskGroup 作用域内,不能在创建它的任务之外使用,并且会在返回前等待其子任务完成。因此,将任务组存为属性,以供彼此无关的后续按钮按下操作使用,并不受支持。对于这个同步计算引擎,保留一个操作队列即可提供所需的生命周期和明确的并发设置。Swift 标准库中的任务组文档介绍了这些限制。

工作安排 合适的方法
独立的同步计算逐个到来 保留一个 OperationQueue,并为每项计算提交一个操作。
父级异步操作管理一组有限的子任务 在父操作的作用域内使用任务组;若需要数值上限,则显式控制任务的接纳。
工作必须更新桌面界面 单独执行计算,然后将结果交给隔离到 MainActor 的代码。

最后一行涉及结果交付。Swift 语言指南指出,主执行器是放置界面状态的地方,并展示了显式隔离到主执行器的闭包。应将耗时的算术运算留在结果交付步骤之外。《Swift 编程语言:并发》介绍了主执行器隔离。

定义计算和完成约定

示例接收一个整数标识符,以及一个从 1 到 1_000_000 的迭代次数。若迭代次数为 n,它会将从 1 到 n 的所有整数的平方相加。返回值包含该标识符和一个 UInt64 结果。标识符用于标记输出,不会影响算术运算。

这些是为本程序选取的示例限制。达到最大允许输入时,结果为 333333833333500000,可以用 UInt64 表示。验证会在提交前进行,因此无效请求会立即抛出错误,不会创建排队操作。输入限制也确保循环范围有效。

每个已接受的请求都会在其操作完成时存储一个结果。结果会累积在数组中,因此重复的标识符不会覆盖较早的结果。演示使用递增且互不相同的标识符,并按标识符排序,以便清晰显示;这种显示顺序并不表示执行顺序。

此约定假设队列始终可用,并且其操作会运行至完成。示例不提供取消功能。若之后添加取消功能,就必须为未完成的请求定义单独的结果状态,因为 Apple 文档指出,取消只是停止请求,正在执行的工作必须自行响应。Apple 的 OperationQueue 文档介绍了操作的生命周期和取消。

使用共享限制构建完整程序

使用配备 Swift 6.2 或更新版本编译器的 Mac,并通过 Xcode 或其命令行工具获取 macOS SDK。将以下完整程序保存为 main.swift。下面的命令会显式选择非隔离的默认隔离模式,使计算辅助函数不受应用目标隔离设置的影响。Swift 在 SE-0466:控制默认 actor 隔离推断中介绍了此编译器设置。

结果日志使用 NSLock 保护其数组和活动计数器。它对 @unchecked Sendable 的符合声明将同步责任交给实现方:每次访问可变状态时都使用同一个锁。Apple 在 Sendable 中说明了这一责任,并在 NSLock 中介绍了临界区保护。

import Foundation

enum InputError: Error {
    case invalidIterations
}

struct Calculation: Sendable {
    let id: Int
    let iterations: Int
}

func sumOfSquares(_ job: Calculation) -> UInt64 {
    var sum: UInt64 = 0
    for integer in 1...job.iterations {
        let value = UInt64(integer)
        sum += value * value
    }
    return sum
}

final class ExecutionLog: @unchecked Sendable {
    private let lock = NSLock()
    private var active = 0
    private var peak = 0
    private var rows: [(id: Int, value: UInt64)] = []

    func begin() {
        lock.lock()
        defer { lock.unlock() }
        active += 1
        peak = max(peak, active)
    }

    func finish(id: Int, value: UInt64) {
        lock.lock()
        defer { lock.unlock() }
        rows.append((id: id, value: value))
        active -= 1
    }

    func snapshot() -> (
        rows: [(id: Int, value: UInt64)], peak: Int
    ) {
        lock.lock()
        defer { lock.unlock() }
        return (rows, peak)
    }
}

final class CalculationPool {
    private let queue = OperationQueue()
    private let log = ExecutionLog()

    init(limit: Int) {
        precondition(limit > 0)
        queue.name = "Synthetic calculations"
        queue.maxConcurrentOperationCount = limit
    }

    func submit(id: Int, iterations: Int) throws {
        guard (1...1_000_000).contains(iterations) else {
            throw InputError.invalidIterations
        }
        let job = Calculation(id: id, iterations: iterations)
        queue.addOperation { [log] in
            log.begin()
            let value = sumOfSquares(job)
            log.finish(id: job.id, value: value)
        }
    }

    // Call only after the producer has stopped submitting work.
    // This blocking method is for the command-line demonstration.
    func finishForDemo() -> (
        rows: [(id: Int, value: UInt64)], peak: Int
    ) {
        queue.waitUntilAllOperationsAreFinished()
        return log.snapshot()
    }
}

@main
struct Demo {
    static func main() throws {
        let limit = 2
        let pool = CalculationPool(limit: limit)

        try pool.submit(id: 1, iterations: 100_000)
        try pool.submit(id: 2, iterations: 200_000)
        try pool.submit(id: 3, iterations: 300_000)

        let report = pool.finishForDemo()
        let rows = report.rows.sorted { $0.id < $1.id }
        precondition(rows.map { $0.id } == [1, 2, 3])
        precondition(rows.map { $0.value } == [
            UInt64(333338333350000),
            UInt64(2666686666700000),
            UInt64(9000045000050000)
        ])
        precondition(report.peak >= 1 && report.peak <= limit)

        for row in rows {
            print("Job \(row.id): \(row.value)")
        }
        print("Observed peak: \(report.peak)")
    }
}

在包含 main.swift 的目录中编译并运行:

swiftc -swift-version 6 -default-isolation nonisolated \
  -parse-as-library main.swift -o calculation-demo
./calculation-demo

应显示以下三行结果:

Job 1: 333338333350000
Job 2: 2666686666700000
Job 3: 9000045000050000

最后一行报告观察到的峰值为 1 或 2。限制允许任务重叠,但不保证一定重叠。预期总和已使用整数运算和公式 n * (n + 1) * (2 * n + 1) / 6 独立核对。程序已检查过,但由于研究环境中未安装 Swift 可执行文件,无法编译。

为什么此限制能覆盖实际计算?

每个操作都会启动活动计数器、完整执行计算、记录结果,然后返回。该闭包不会再启动其他异步计算。因此,限制正在执行的操作也就限制了这些计算。

调整程序时要保持这一关系。如果某个操作只是创建一个单独的 Task,然后立即返回,那么队列会认为操作已经结束,而该任务可能仍在继续计算。此时操作限制管理的只是短暂的启动闭包。这是由文档所述的操作生命周期和闭包提交 API 决定的。Apple 的 OperationQueue 概览和 addOperation(_:)定义了这些机制。

日志的锁只保护少量记账代码。在 sumOfSquares(_:) 期间持有该锁会串行化计算访问,妨碍预期的重叠。每次加锁和解锁都在同一个同步方法内完成,符合 Apple 关于 NSLock 必须由加锁的线程解锁的要求。Apple 的 NSLock 文档说明了这项要求。

检查改动后的程序时,将相同的三个请求运行一次,并把 limit 设为 1,然后恢复为 2。两次运行都必须保留全部三个精确结果。串行运行应报告峰值 1;第二次运行则必须不超过 2。这些检查可验证结果是否保留,以及观察到的计算并发数是否受限,同时不假设特定的调度顺序。

连接传入的桌面操作,同时避免阻塞界面

在负责桌面计算功能的组件中保留一个 CalculationPool。每个传入操作都调用一次 submit(id:iterations:)。避免在操作处理程序中重新创建池,因为每个新实例都有独立的队列和限制。

演示只在最后一次提交之后调用 finishForDemo(),这样命令行进程就能在退出前收集结果。此方法会阻塞调用它的线程。在桌面应用中,应使用逐个结果交付,而不要在按钮处理程序或主执行器上调用此方法。Apple 明确说明了 waitUntilAllOperationsAreFinished() 的阻塞行为。

要集成界面,可扩展提交 API,添加一个隔离到 MainActor 并标记为 @Sendable 的结果回调。在操作内计算出结果后,使用带显式 @MainActor 闭包的 Task 来调用该回调。捕获不可变的结果值和回调。只在该闭包内更新显示内容;算术运算仍留在操作中。Swift 介绍了主执行器闭包语法和可发送闭包的捕获内容。

此界面版本还需要进一步明确完成的定义:计算完成和结果显示是两个独立事件。命令行程序会等待计算完成并同步存储结果。单独调度的界面回调可能在之后才执行。

提交速度超过计算速度时会怎样?

并发设置会限制正在执行的操作数量,而其他已提交的工作可以留在队列中等待。示例中已完成结果的数组也可以继续增长。因此,这个示例限制了计算并发数,却没有限制总内存用量或积压量。Apple 文档指出,队列会保留操作,直到它们完成。OperationQueue 概览介绍了保留行为。

如果桌面功能可能持续遭遇突发请求,应在扩展功能前选择一种接纳策略:只接受固定数量的未完成请求并报告拒绝,暂停后续提交,或者仅在明确需要这种行为时,用较新的请求替换尚未处理的合成预览。通过一个同步的所有者来协调接纳计数和完成更新。仅限制正在运行的工作无法实现这些策略。

对于逐个到来的同步请求,核心设计很简单:保留一个队列,设置一次并发限制,将每项计算留在其操作中,并同步共享结果。连接桌面操作时保持这些边界;只有在请求到达模式确有需要时,才添加明确的积压策略。

来源