要并行运行 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 用户启动或取消一项模拟计算,并显示最终校验和。所有输入都是常量或生成的整数。计算遵循以下精确定义:
- 恰好访问
0..<count中的每个整数索引一次。 - 将每个索引转换为
UInt64,并按指定轮数反复应用x = (x &* 1_664_525) &+ 1_013_904_223。 - 使用环绕加法累加结果值。
- 空输入返回零;负数的 count 或 rounds 会被拒绝;非正数的 worker limit 也会被拒绝。
- 如果观察到取消状态,就抛出错误,而不是将不完整的校验和当作完成结果显示。
这里有意使用环绕算术。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 管理界面状态,并发工作函数接收独立值,父任务合并完整结果。调整分块大小时请保持值定义不变,并在保留性能改动前验证校验和一致且界面保持响应。
来源
- 拥抱 Swift 并发 — Apple。
- 默认在调用方 actor 上运行非隔离异步函数 — Swift.org,Swift Evolution。
- 允许使用 nonisolated 来阻止全局 actor 推断 — Swift.org,Swift Evolution。
- 并发 — Swift.org,《Swift 编程语言》。
- 高级运算符 — Swift.org,《Swift 编程语言》。
- Task — Apple 开发者文档。
- TaskGroup — Apple 开发者文档。
- activeProcessorCount — Apple 开发者文档。
- TaskGroup.swift — Swift.org,Swift 标准库。