要并行运行 CPU 密集型 Swift 计算,请为工作函数明确设置 @concurrent 边界,并将相互独立的工作分配给子任务。这个 Mac 示例让界面更新留在 MainActor 上,并在不共享可变状态的情况下合并模拟结果。

为什么添加任务后计算仍可能串行执行

假设 Mac 应用有一个 Calculate 按钮,用来启动一项耗时较长的本地计算。将计算包装在 Task { ... } 中会提供一个异步入口,但在主 actor 上下文中创建的任务可能会继承该隔离。任务中的长时间同步循环仍会占用主 actor。Apple 在拥抱 Swift 并发中展示了这一区别。

添加任务组也没有回答一个重要问题:包含耗时循环的函数究竟在哪里执行?如果每个子任务都调用同一个 @MainActor 计算方法,该 actor 就会串行处理计算。正如 Swift 语言指南的并发章节所述,actor 一次只允许一个任务访问其隔离状态。

对于 Swift 6.2 及更高版本,请将异步计算入口标记为 @concurrent。这会明确将执行切换到调用方 actor 之外。启用 NonisolatedNonsendingByDefault 特性后,普通的非隔离异步函数会在调用方的 actor 上运行;旧版行为有所不同。已实施的 SE-0461 提案记录了该特性适用的版本范围和显式属性。

需要分别做出两个选择,才能实现预期行为:工作函数的隔离方式允许它在 MainActor 之外执行,而多个子任务则提供可独立调度的工作。运行时会决定由哪些可用线程来执行这些任务。任务数量和 @concurrent 都不保证同时执行的核心数。Apple 在拥抱 Swift 并发中介绍了这种调度模型。

定义可正确拆分的计算

下面的示例允许 Mac 用户启动或取消一项模拟计算,并显示最终校验和。所有输入都是常量或生成的整数。计算遵循以下精确定义:

  1. 恰好访问 0..<count 中的每个整数索引一次。
  2. 将每个索引转换为 UInt64,并按指定轮数反复应用 x = (x &* 1_664_525) &+ 1_013_904_223。
  3. 使用环绕加法累加结果值。
  4. 空输入返回零;负数的 count 或 rounds 会被拒绝;非正数的 worker limit 也会被拒绝。
  5. 如果观察到取消状态,就抛出错误,而不是将不完整的校验和当作完成结果显示。

这里有意使用环绕算术。Swift 的普通整数运算在溢出时会触发陷阱;&+ 和 &* 运算符则明确支持溢出行为。请参阅高级运算符。

对于这个定义,每个索引都彼此独立,环绕加法会将部分总和按 2 的 64 次方取模后合并。因此,改变分组方式或完成顺序不会改变最终值。这一结论源于所选的算术方式。浮点计算需要单独定义正确性规则,因为重新分组相加可能改变舍入结果。

分区使用除法和余数:每个工作函数会分到相同的基础索引数量,前几个工作函数各多分到一个索引。举例来说,count 为 17、worker 数量为 3 时,范围分别是 0..<6、6..<12 和 12..<17。这些范围会覆盖整个输入,且恰好一次,包括余数部分。

构建完整的 Mac 计算示例

使用 Swift 6.2 或更高版本的工具链,以及最低部署目标为 macOS 13 或更高版本的 macOS SwiftUI 应用目标。将生成的应用入口点和内容视图替换为下面两个代码块,并放在同一个 Swift 文件中。它们构成一个完整程序;目标中只保留一个 @main 应用声明。

计算命名空间显式声明为 nonisolated,因此其成员不会获得推断得出的全局 actor 隔离。Swift 在 SE-0449 中介绍了这种类型级声明。两个异步计算方法都使用 @concurrent 来明确声明执行边界。

import Foundation
import SwiftUI

nonisolated enum CalculationError: Error {
    case invalidInput
}

nonisolated enum SyntheticCalculator {
    @concurrent
    static func run(
        count: Int,
        rounds: Int,
        workerLimit: Int
    ) async throws -> UInt64 {
        guard count >= 0, rounds >= 0, workerLimit > 0 else {
            throw CalculationError.invalidInput
        }
        try Task.checkCancellation()
        guard count > 0 else { return 0 }

        let workers = min(count, workerLimit)
        let baseSize = count / workers
        let remainder = count % workers

        return try await withThrowingTaskGroup(
            of: UInt64.self,
            returning: UInt64.self
        ) { group in
            var lower = 0

            for worker in 0..<workers {
                try Task.checkCancellation()
                let size = baseSize + (worker < remainder ? 1 : 0)
                let start = lower
                let end = start + size
                lower = end

                group.addTask {
                    try await calculateChunk(
                        lower: start,
                        upper: end,
                        rounds: rounds
                    )
                }
            }

            var total: UInt64 = 0
            for try await partial in group {
                total = total &+ partial
            }
            try Task.checkCancellation()
            return total
        }
    }

    @concurrent
    private static func calculateChunk(
        lower: Int,
        upper: Int,
        rounds: Int
    ) async throws -> UInt64 {
        try Task.checkCancellation()
        var total: UInt64 = 0

        for index in lower..<upper {
            if index % 256 == 0 {
                try Task.checkCancellation()
            }
            var value = UInt64(index)
            for round in 0..<rounds {
                if round % 4096 == 0 {
                    try Task.checkCancellation()
                }
                value = (value &* 1_664_525) &+ 1_013_904_223
            }
            total = total &+ value
        }

        try Task.checkCancellation()
        return total
    }
}

每个子任务都会捕获不可变的范围边界,并返回一个整数。只有父任务会更新 total;子任务不会向共享集合追加内容,也不会修改界面状态。任务组支持子任务并发执行,要求结果符合 Sendable,并将子任务的生命周期限制在组的作用域内。请参阅 Apple 的 TaskGroup 文档。

将以下界面代码追加到同一个文件中:

@main
@MainActor
struct ParallelCalculationApp: App {
    var body: some Scene {
        WindowGroup {
            CalculationView()
        }
    }
}

@MainActor
struct CalculationView: View {
    @State private var status = "Ready"
    @State private var work: Task<Void, Never>?

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Synthetic Calculation")
                .font(.title2)
            Text(status)
                .textSelection(.enabled)

            HStack {
                Button("Calculate") {
                    startCalculation()
                }
                .disabled(work != nil)

                Button("Cancel") {
                    work?.cancel()
                }
                .disabled(work == nil)
            }
        }
        .padding(24)
        .frame(minWidth: 440, minHeight: 180)
        .onDisappear {
            work?.cancel()
        }
    }

    private func startCalculation() {
        guard work == nil else { return }
        status = "Calculating…"
        let workerLimit = max(
            1,
            ProcessInfo.processInfo.activeProcessorCount
        )

        work = Task { @MainActor in
            defer { work = nil }
            do {
                let checksum = try await SyntheticCalculator.run(
                    count: 1_000_000,
                    rounds: 128,
                    workerLimit: workerLimit
                )
                try Task.checkCancellation()
                status = "Checksum: \(checksum)"
            } catch is CancellationError {
                status = "Cancelled"
            } catch {
                status = "Calculation failed: \(error)"
            }
        }
    }
}

界面持有外层任务句柄,因此 Cancel 按钮可以取消该任务。此任务仍隔离在主 actor 上,会调用并发计算,并在等待结果后更新显示的状态。持有句柄也可以在视图消失时取消任务。Apple 的 Task 文档说明,丢弃句柄不会取消任务。

工作函数数量上限初始采用 Foundation 的 activeProcessorCount,该属性会报告计算机上可用的活跃处理器核心数。应将其视为待测量的初始配置,而不是保证最优的任务数量。其文档说明见 activeProcessorCount。

比较性能前先检查正确性

先从较小的示例输入开始:将调用改为 count: 17、rounds: 4 和 workerLimit: 3。预期校验和是 18149427060254280572。再将 workerLimit 设为 1 并重复计算;结果必须一致。还要检查 count: 0,它应返回零;以及小于 worker limit 的正数 count,它仍应包含每个索引。

示例校验和及范围覆盖情况已通过一个用于复现环绕算术的小型 Python 程序验证。研究环境中没有 Swift 编译器,因此这里尚未编译或运行该 Swift 程序。

接下来恢复较大的输入,使用 worker limit 为 1、2 和活跃处理器数量的优化构建进行比较。保持 count 和 rounds 不变,记录计算出的校验和,并测量多次运行。这是一套建议的测量流程;这里不声称任何耗时或加速效果。

worker 数量为 1 时,仍会使用相同的并发边界和任务组结构,因此可以作为此实现的有用对照。小型工作负载可能不值得承担分区和调度开销。如果现实中的计算在不同索引上的耗时差异很大,等长范围也可能导致某个工作函数完成得更晚;可将下一步实验设为使用更小的分块,并限制同时执行的任务数量。

对于生成数组的计算,应为每个结果附上分块索引,再按输入顺序组装分块。任务组的子任务可以按任意顺序执行,组会随着结果完成而逐个返回结果。只有因为所选归约方式与顺序无关,校验和才不需要考虑顺序。请参阅 TaskGroup。

让取消和隔离问题清晰可见

取消是协作式的。调用 cancel() 会标记任务;计算代码必须检查该状态。两个循环中的检查为工作函数提供了响应取消的机会,包括单个索引需要执行许多轮的情况。这些检查间隔是实现上的选择,并不保证特定的取消延迟。Apple 在 Task 中说明了这项责任。

抛出错误的结果循环也很重要。如果某个子任务抛出错误,且错误从组体中传播出来,剩余子任务就会被取消。任务组仍会等待这些子任务结束,因此取消检查仍然必不可少。Swift 标准库的实现文档 TaskGroup.swift 记录了这一行为。

如果界面仍然卡顿,请追踪完整的耗时调用路径。检查工作函数是否调用了主 actor 方法、访问了 actor 隔离属性,或是在到达并发边界前执行了大量准备工作。将所需的整数值传入工作函数,并让长时间循环留在该边界内。

如果编译器拒绝 @concurrent,请检查工具链版本。该属性在 Swift 6.2 中实现,适用于异步函数。对于更旧的工具链,SE-0461 中记录了早期非隔离异步执行模型;请使用与该编译器版本匹配的文档,不要假设所有版本对 nonisolated async 的行为都相同。

对于这项 Mac 任务,所需架构很简单:主 actor 管理界面状态,并发工作函数接收独立值,父任务合并完整结果。调整分块大小时请保持值定义不变,并在保留性能改动前验证校验和一致且界面保持响应。

来源