使用一个长期存在的 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 概览介绍了保留行为。
如果桌面功能可能持续遭遇突发请求,应在扩展功能前选择一种接纳策略:只接受固定数量的未完成请求并报告拒绝,暂停后续提交,或者仅在明确需要这种行为时,用较新的请求替换尚未处理的合成预览。通过一个同步的所有者来协调接纳计数和完成更新。仅限制正在运行的工作无法实现这些策略。
对于逐个到来的同步请求,核心设计很简单:保留一个队列,设置一次并发限制,将每项计算留在其操作中,并同步共享结果。连接桌面操作时保持这些边界;只有在请求到达模式确有需要时,才添加明确的积压策略。
来源
- OperationQueue — Apple。
- maxConcurrentOperationCount — Apple。
- addOperation(_:) — Apple。
- waitUntilAllOperationsAreFinished() — Apple。
- NSLock — Apple。
- Sendable — Apple。
- 并发 —《Swift 编程语言》 — Swift.org。
- TaskGroup.swift — Swift 标准库源码和文档 — Swift.org。
- SE-0466:控制默认 actor 隔离推断 — Swift.org。