批量处理二十份附件时,把所有任务塞进任务组,再逐个读取结果,并不等于限制并发。网络连接、解码缓冲区和临时文件仍可能同时增长。在云端 Mac 上验收这段逻辑,重点不是跑得多快,而是证明:上限为三时,第四个操作必须等到空位出现。
下面把调度器从网络实现中抽离。先用可控操作验证槽位,再接入 iOS 业务代码。这样失败时能区分调度错误与外部服务波动,不必靠反复重跑寻找结论。
先定义可检查的调度契约
约定输入顺序就是输出顺序,完成顺序可以不同;空输入返回空数组;并发上限必须大于零。任意子任务抛错后,整体失败,不交付部分结果。父任务取消后,不应继续主动补入待执行工作。
这里限制的是传入操作的活跃数量,不是线程数,也不是请求速率。一个操作若在内部另开多个请求,这些请求不在外层计数范围内。每秒请求配额需要单独实现,不能拿并发槽位代替。
取消是协作信号,不是强制终止。任务组离开作用域前仍会等待子任务结束;忽略取消的操作会拖住整个调用。
用完成事件补入下一个任务
将以下代码保存为 BoundedMap.swift。使用已安装 Swift 工具链的云端 Mac 执行,先以 swift --version 记录版本。示例采用严格并发所需的 Sendable 约束,不依赖图形会话或模拟器。
import Foundation
func boundedMap<Input: Sendable, Output: Sendable>(
_ inputs: [Input],
limit: Int,
operation: @escaping @Sendable (Input) async throws -> Output
) async throws -> [Output] {
precondition(limit > 0)
return try await withThrowingTaskGroup(
of: (Int, Output).self
) { group in
var nextIndex = 0
var completed: [(Int, Output)] = []
completed.reserveCapacity(inputs.count)
for index in 0..<min(limit, inputs.count) {
try Task.checkCancellation()
let input = inputs[index]
group.addTask {
try Task.checkCancellation()
return (index, try await operation(input))
}
nextIndex += 1
}
while let result = try await group.next() {
completed.append(result)
try Task.checkCancellation()
if nextIndex < inputs.count {
let index = nextIndex
let input = inputs[index]
group.addTask {
try Task.checkCancellation()
return (index, try await operation(input))
}
nextIndex += 1
}
}
try Task.checkCancellation()
return completed.sorted { $0.0 < $1.0 }.map { $0.1 }
}
}
初始只提交上限数量。之后每收到一个完成结果,才提交一个新任务,因此不会因为输入很长而创建同样数量的等待任务。索引随结果返回,最后恢复输入顺序,避免把“先完成”误当成“排在前面”。
错误由 group.next() 传播;闭包抛错退出时,任务组会取消剩余子任务并等待收尾。这里没有捕获错误后继续补任务,也没有使用脱离父任务生命周期的独立后台任务。
用 actor 探针测量实际活跃数
把下面代码接在同一文件末尾,再执行 swift BoundedMap.swift。探针在操作进入时加一,退出时减一;睡眠期间 actor 可以接收其他调用,因此不会把所有工作意外串行化。
actor Probe {
private var active = 0
private var peak = 0
func run(_ value: Int) async throws -> Int {
active += 1
peak = max(peak, active)
defer { active -= 1 }
let delay = UInt64(5 - value % 5) * 10_000_000
try await Task.sleep(nanoseconds: delay)
return value * 2
}
func snapshot() -> (active: Int, peak: Int) {
(active, peak)
}
}
let probe = Probe()
let inputs = Array(0..<20)
let output = try await boundedMap(inputs, limit: 3) {
try await probe.run($0)
}
let state = await probe.snapshot()
precondition(output == inputs.map { $0 * 2 })
precondition(state.active == 0)
precondition(state.peak <= 3)
不同延迟用于打乱完成顺序,不是性能基准。不要断言某个任务必须在某毫秒结束,也不要把机器较忙导致的峰值不足三判成错误。必须成立的是峰值不越界、结果完整、退出后活跃数归零。
还应分别使用空数组、单个元素和上限一运行。调用方如果接收配置文件中的上限,需先拒绝零与负数;示例的前置条件适合暴露程序错误,不适合直接处理用户输入。
失败与取消必须单独验收
成功用例不能证明取消正确。先构造一个指定输入必定抛错的操作,断言调用失败且探针最终归零。不要断言失败前恰好启动了多少个任务:错误到达任务组之前,其他任务可能已经完成并触发补位。
取消用例应使用可控闸门:让首批操作报告“已进入”,测试收到这些信号后取消父任务,再释放闸门。操作越过闸门后检查取消,测试等待父任务结束,最后确认所有已进入的操作都已退出。
不要用固定睡眠猜测任务是否启动。闸门本身也必须能释放所有等待者,否则测试会把夹具死锁误判成调度器卡死。验收报告应区分取消请求时间、操作退出时间与父任务返回时间。
接回业务时守住槽位边界
网络下载应把下载、必要解码和临时文件收尾放在同一个操作作用域内。若收到响应就释放槽位,却把大图解码丢给后台任务,网络并发虽然受控,内存峰值仍然失控。
这份实现会保存全部输出,空间随结果总量增长。处理大文件时宜返回文件位置与小型摘要,而不是把文件内容全装入数组。需要边完成边消费的流水线,应另定输出背压契约,不能直接声称此实现具有恒定内存占用。
最终留存工具链版本、测试输入、上限、峰值及退出计数。把调度器测试放进普通单元测试目标,再为真实操作补上取消与资源释放检查。只要能稳定证明槽位、顺序和收尾三个边界,换到另一台云端 Mac 时就有了可复核的行为基线。
常见问题
给每个任务加一段延迟,能限制并发吗?
不能。延迟只改变启动时间,不保证活跃任务数量。应只提交上限数量的子任务,每完成一个再补入一个。
任务组取消后会立即返回吗?
不会保证立即返回。结构化任务组退出前会等待子任务结束,操作必须响应取消并结束底层工作。