Swift并发模型中最容易被误解的一点,就是任务取消并不会"杀死"一个正在执行的协程。取消只是一个信号,子任务收到信号后是否停止、何时停止、如何停止,完全取决于代码是否主动配合。如果TaskGroup里的子任务没有做取消检查,即使父任务早已被取消,这些子任务也会继续跑完整个计算过程,白白消耗CPU和网络资源。本文从取消传播的底层机制讲起,分析常见的错误写法,并给出确保所有子任务正确响应取消的实践方案。

取消传播的底层机制:信号如何从父任务到达子任务
Swift的结构化并发基于任务树构建。当你通过withTaskGroup或withThrowingTaskGroup创建任务组时,组内添加的每个子任务都会成为当前任务的子节点。取消传播遵循一条核心规则:父任务被取消时,运行时会自动把取消标记设置到它的所有子任务上,这个过程是运行时自动完成的,不需要开发者手动干预。
但这里的关键在于,"设置取消标记"和"停止执行"是两件事。运行时不会中断已经运行的代码,它只是把任务内部的一个原子标志位改为已取消状态。子任务要真正停下来,必须通过以下几种方式之一主动响应:
- 调用
Task.checkCancellation(),如果任务已取消会立即抛出CancellationError - 读取
Task.isCancelled或Task.isCancelled的异步版本,自行决定退出逻辑 - 调用支持取消的异步API(如
Task.sleep、URLSession的async方法),这些API内部会在取消时抛出错误 - 通过
withTaskCancellationHandler注册自定义取消回调
下面这个例子演示了取消传播的基本行为。父任务在0.5秒后取消,子任务的循环每次迭代都检查取消状态:
func fetchAll() async throws {
let task = Task {
try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask {
for i in 0..<1000 {
// 每次迭代检查取消状态,及时退出
try Task.checkCancellation()
try await Task.sleep(nanoseconds: 100_000_000)
print("子任务1 进度 \(i)")
}
return Data()
}
group.addTask {
// 这个子任务没有检查取消,会一直跑完
for i in 0..<5 {
try await Task.sleep(nanoseconds: 300_000_000)
print("子任务2 进度 \(i)")
}
return Data()
}
var results: [Data] = []
for try await data in group {
results.append(data)
}
return results
}
}
// 0.5秒后取消父任务
try await Task.sleep(nanoseconds: 500_000_000)
task.cancel()
let result = try await task.value
print("结果数量: \(result.count)")
}运行这段代码会发现,子任务1在被取消后迅速退出并抛出CancellationError,而子任务2因为没有配合检查,会继续打印进度直到循环结束。整个任务组要等所有子任务都返回后才能结束,这就导致父任务的取消"看起来没生效"。理解了这一点,就明白为什么苹果官方文档反复强调Swift的取消是协作式的。
常见陷阱:这些写法会让取消形同虚设
第一个陷阱是长循环中缺少检查点。如果一个子任务要对十万条数据做处理,但整个循环没有任何取消检查,那么取消信号要等循环全部跑完才能体现出来。正确做法是在循环体内定期调用Task.checkCancellation(),或者在分批处理时以批为单位检查。检查本身开销极小,几乎不影响性能。
第二个陷阱是对不支持协作式取消的旧API不做桥接。比如一个基于闭包回调的老式网络库,它根本不知道Swift任务的存在,父任务取消后回调依然会正常触发。这时候需要用withTaskCancellationHandler把取消信号转换成该库能理解的操作,比如调用它的cancel方法:
func legacyRequest(url: URL) async throws -> Data {
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
let requestId = LegacyHttpClient.shared.get(url) { result in
continuation.resume(with: result)
}
// 保存id供取消时使用
TaskHolder.shared.currentId = requestId
}
} onCancel: {
// 父任务取消时,通知旧网络库中断请求
if let id = TaskHolder.shared.currentId {
LegacyHttpClient.shared.cancel(id)
}
}
}第三个陷阱是在任务组的结果收集循环中提前break。有些开发者拿到足够的结果后直接跳出for await循环,以为任务组会自动结束。实际上,提前break之后必须显式调用group.cancelAll(),否则剩余的子任务仍在运行,任务组的退出会一直挂起等待它们完成:
let urls = ["https://ipipp.com/a", "https://ipipp.com/b", "https://ipipp.com/c"]
let images = try await withThrowingTaskGroup(of: UIImage?.self) { group in
for url in urls {
group.addTask { await self.downloadImage(url) }
}
var collected: [UIImage] = []
for try await image in group {
guard let image = image else { continue }
collected.append(image)
if collected.count == 2 {
// 只需要2张,提前退出前必须取消剩余任务
group.cancelAll()
break
}
}
return collected
}确保正确取消的实践方案与资源清理
综合前面的分析,一套可靠的取消处理模式包含三个层面:入口处桥接取消信号、循环中设置检查点、退出前做资源清理。资源清理建议使用defer块,它保证无论任务是正常完成、抛出错误还是响应取消退出,清理逻辑都会执行:
struct ResourceWorker {
var handle: FileHandle?
func processFile() async throws -> [String] {
defer {
// 无论正常结束还是被取消,都关闭文件句柄
handle?.closeFile()
handle = nil
}
var lines: [String] = []
while let line = readNextLine() {
// 协作式取消检查点
try Task.checkCancellation()
lines.append(line)
}
return lines
}
private func readNextLine() -> String? { nil }
}在任务组层面,还有一个容易忽略的细节:如果子任务是同步的重计算,长函数内也应穿插检查。另外,iOS 17之后可用的DiscardingTaskGroup适用于"消费即丢"的场景,它会在子任务完成后立即丢弃结果,配合取消传播可以高效实现生产者-消费者模式。判断取消时机时,Task.isCancelled适合需要优雅收尾的场景(比如保存中间结果再退出),而Task.checkCancellation()适合直接终止的场景,两者可以组合使用。
最后需要明确一点:父任务取消后,await task.value会抛出CancellationError,但这只代表任务树进入取消状态,不代表子任务已经停止。如果你的代码需要在取消后确认所有资源已释放(比如关闭数据库连接池),应该把清理逻辑放在子任务内部的defer块中,而不是依赖父任务的外层捕获。掌握这些规则后,TaskGroup在取消场景下的行为就完全可预期了。
Swift TaskGroup任务取消结构化并发修改时间:2026-09-03 09:33:00