在Swift并发编程中,TaskGroup是管理并发子任务的利器,但不少开发者遇到过这样的诡异现象:明明已经对任务调用了cancel(),甚至TaskGroup的withTaskGroup作用域已经返回了,里面的某些子任务却依然在执行。这些没有被真正终止的任务,通常被称作僵尸任务。它们会持续占用线程池资源,还可能在你以为已经收尾的地方继续写入数据,造成难以排查的数据竞争。要理解并解决这一问题,首先要弄清Swift取消机制的本质。

取消是协作式的:cancel只是发出信号而非强制终止
Swift的任务取消与某些语言中的线程中断或强杀不同,它是一种协作式取消模型。调用task.cancel()时,运行时做的事情非常有限:将任务内部的取消标志位标记为已取消,并向该任务及其所有子任务的取消处理逻辑发送通知。仅此而已,任务本身并不会被暂停或终止。
换句话说,取消是否真正生效,完全取决于任务内部的代码是否主动配合。协作的方式主要有三种:第一种是调用Task.checkCancellation(),若任务已被取消则抛出CancellationError;第二种是轮询检查Task.isCancelled属性,在合适的时机自行退出;第三种是使用系统的可取消原语,比如Task.sleep、URLSession的异步请求等,这些API内部已经实现了对取消信号的响应,收到取消信号后会立即中断并抛出错误。
很多僵尸任务的根源就在这里:子任务内部是一个纯计算的长循环,或者调用了不响应取消的阻塞式API,取消信号自然石沉大海。下面是一个典型的问题代码:
func fetchAll() async throws -> [String] {
let result = try await withTaskGroup(of: String.self) { group in
for i in 0..<10 {
group.addTask {
// 问题代码:纯计算循环,从不检查取消状态
var total = 0
for j in 0..<1_000_000_000 {
total += j
}
return "task \(i) done \(total)"
}
}
var collected: [String] = []
for await item in group {
collected.append(item)
}
return collected
}
return result
}即使外层任务被取消,这10个子任务中的每一个都会把十亿次循环老老实实跑完,因为循环体内没有任何检查取消的代码。把循环改造成每隔一定步数调用一次Task.checkCancellation(),就能让任务在被取消后迅速抛出异常退出:
group.addTask {
var total = 0
for j in 0..<1_000_000_000 {
// 每一百万次迭代检查一次取消,兼顾性能与响应性
if j % 1_000_000 == 0 {
try Task.checkCancellation()
}
total += j
}
return total
}TaskGroup的隐式取消传播规则与作用域陷阱
除了显式调用cancel(),TaskGroup还有一条隐式取消规则:当withTaskGroup的闭包抛出错误返回时,所有尚未完成的子任务会被自动标记为取消。这条规则看似贴心,却经常被误解为子任务会被自动回收。实际上自动发生的只是标记,子任务是否真正退出仍取决于它自己的代码。
更隐蔽的一个陷阱是:如果闭包抛出错误后你既没有等待也没有消费掉剩余结果,子任务的状态会变得不明确。正确的做法是在错误路径上显式处理取消并等待所有子任务结束。来看一个健壮的写法:
func loadAll() async throws -> [String] {
try await withThrowingTaskGroup(of: String.self) { group in
for url in urls {
group.addTask { try await loadOne(url) }
}
do {
var results: [String] = []
for try await item in group {
results.append(item)
}
return results
} catch {
// 显式标记取消,并等待所有子任务真正退出后再抛出
group.cancelAll()
// 消费剩余结果,确保没有子任务在作用域外继续存活
for try await _ in group {}
throw error
}
}
}这里的关键点有两个:group.cancelAll()立即向所有子任务广播取消信号,而随后的空for try await循环则保证在抛出错误之前,所有子任务都已经真正结束。缺少这一步,虽然withTaskGroup返回时内部也会隐式等待所有子任务完成,但显式写出可以让取消逻辑一目了然,也便于在中间插入资源清理代码。
还需要注意,取消标志是从父任务向子任务单向传播的,兄弟任务之间互不影响。如果你只取消了其中一个子任务,其他子任务不会收到任何信号。同时,取消传播在运行时层面几乎是即时的,所谓传播延迟,绝大多数情况是子任务的代码在长时间不检查取消状态后才响应导致的假象,而非信号本身的延迟。
常见僵尸任务场景与系统排查方法
梳理几个高频踩坑场景。第一类是Task.sleep本身没问题,但开发者用第三方库的延时函数替代,而该库内部不响应取消,取消信号就无法穿透。第二类是在子任务里通过Task {}又创建了非结构化任务,这类任务游离于结构化并发树之外,父任务的取消对它们完全无效,这是最经典也是最难发现的一种僵尸来源。第三类是子任务内部调用了同步阻塞代码(如同步IO、重量级锁等待),既不抛出也不会被打断。
排查时可以按以下清单进行:第一,检查子任务内是否存在Task {}、Task.detached等非结构化任务的创建点,如果必须使用,要显式管理其生命周期,例如保存Task句柄并在取消时调用handle.cancel();第二,在长循环与批次处理中确认有定期的checkCancellation调用;第三,对第三方异步API查阅文档,确认其是否响应协作式取消;第四,利用Xcode的Task树调试器(Debug面板中的Task选项)直观查看任务层级与状态,被取消但未结束的任务会明确显示出来,这是定位僵尸任务最直观的手段。
最后总结一套防御性写法:所有可能长时间运行的异步函数开头先调用try Task.checkCancellation()做快速失败;循环体内周期性检查;对外暴露的异步接口在文档中写明取消语义;结构化任务树中避免混入非结构化任务。只要让每一层代码都诚实地响应取消信号,僵尸任务问题就能从根本上被消除。