在 Swift 并发编程中,TaskGroup 允许在单个父任务中同时启动多个子任务,并通过循环收集结果。但很多人对取消后的行为存在误解:当父任务被取消时,已经创建的子任务并不会被系统强制杀死,而是需要每个子任务主动响应取消。这就导致资源清理和异常传播成为两个容易出错的环节。

TaskGroup 的取消语义与协作式取消传播
理解 TaskGroup 的取消问题,首先要明确 Swift 并发中的取消是协作式的。调用 group.cancelAll() 或父任务被取消,并不会立即停止子任务的执行。系统只是把子任务的取消标志位置为 true,接下来是否停止、何时停止,完全取决于子任务自身的实现。子任务可以通过 Task.isCancelled 判断是否被请求取消,也可以通过调用 try Task.checkCancellation() 在取消时抛出 CancellationError 来中断执行。
许多开发者误以为只要父任务取消,子任务中正在进行的网络请求或文件操作就会自动中断。实际情况是,如果子任务没有检查取消标志,也没有调用任何支持取消的系统 API,它会继续运行直到自然结束。这就意味着原本应该释放的句柄、临时文件、网络连接等资源可能迟迟得不到清理。正确的做法是在子任务的关键循环中定期检查取消状态,尤其是处理大批量数据或耗时操作时。
下面是一段典型的没有响应取消的例子:子任务在循环中处理数据,但从不检查取消标志,导致父任务取消后子任务仍然运行很久。改进方式是在循环体内加入 try Task.checkCancellation(),让取消请求能够及时中断执行并触发清理逻辑。
func processFile(_ url: URL) async throws {
let handle = try FileHandle(forReadingFrom: url)
defer {
try? handle.close()
}
while let data = try handle.read(upToCount: 1024) {
// 模拟数据处理
try await Task.sleep(nanoseconds: 100_000_000)
}
}
加入取消检查后,循环会在父任务取消时抛出错并离开作用域,随后 defer 中的关闭句柄代码执行。这是保证资源清理的基础,但仅靠 defer 在复杂场景中还不够,还需要结合取消处理闭包来覆盖更加及时的清理需求。
用 defer 与 withTaskCancellationHandler 保证清理
defer 是 Swift 中保证清理逻辑执行的核心工具。无论函数正常返回还是抛出错误,defer 块都会在当前作用域结束时执行。因此,在子任务中打开文件、分配内存、创建网络连接等操作后,紧跟一个 defer 块来释放资源,是一种非常可靠的模式。然而,defer 并不能解决所有问题。例如一个子任务可能长时间挂起在某个不支持取消的阻塞调用上,即使已经请求取消,defer 也要等到这个阻塞调用返回后才会执行,这可能远远晚于预期。
为了让取消请求能够更快地触发清理,Swift 提供了 withTaskCancellationHandler。它可以在任务被取消时立刻执行一段同步的清理闭包,而主操作仍然继续执行。这个清理闭包必须是轻量且快速的,不能包含异步操作或长时间阻塞代码。典型用途包括关闭文件句柄、释放锁、删除临时文件、设置一个标记等。
下面的代码展示了如何把 defer 与 withTaskCancellationHandler 配合使用。主操作在处理文件时,如果父任务被取消,onCancel 闭包会立即关闭文件句柄,从而避免资源长时间占用。同时 defer 作为兜底,在正常结束或出错时也会关闭句柄,双保险。
func readFileWithCancellationHandler(_ url: URL) async throws {
let handle = try FileHandle(forReadingFrom: url)
defer {
try? handle.close()
}
try await withTaskCancellationHandler {
while let data = try handle.read(upToCount: 1024) {
try Task.checkCancellation()
// 处理数据
}
} onCancel: {
try? handle.close()
}
}
需要注意,onCancel 闭包可能会在任意线程上执行,而且可能在主操作还在使用资源时就被调用。如果清理操作与主操作之间存在竞争,必须额外加锁或使用原子状态来保证线程安全。在实际开发中,更常见的做法是使用 withTaskCancellationHandler 设置一个“取消已请求”的标志,然后在主操作的关键点检查该标志,而不是直接在 onCancel 中释放资源。这样可以避免并发访问同一资源带来的风险。
异常传播:如何保留原始错误并避免挂起
当使用 withThrowingTaskGroup 启动多个可能抛出错误的子任务时,异常传播的路径需要格外小心。如果在 for try await result in group 的循环中某个子任务抛出错误,这个错误会作为第一个错误从 group.next() 抛出。但此时 Group 中可能还有其它子任务仍在执行。如果你直接在 catch 中抛出错误并退出 withThrowingTaskGroup 闭包,父任务会等待所有未完成的子任务结束,这可能导致延迟,甚至在某些情况下造成死锁。
更稳健的模式是:在捕获到第一个错误后,先调用 group.cancelAll() 请求剩余子任务取消,然后继续调用 try? await group.next() 消耗掉所有剩余子任务,直到返回 nil。这样做可以保证每个子任务都执行完自己的 defer 清理块,不会留下资源泄漏。之后再把原始错误抛给调用方。
下面的代码演示了这一模式。它启动多个下载任务,一旦任何一个任务失败,就会取消其余任务,并继续等待它们结束,最终抛出第一个捕获到的错误。
func downloadAllFiles(urls: [URL]) async throws {
var firstError: Error?
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask {
try await downloadFile(from: url)
}
}
do {
for try await data in group {
// 正常处理下载结果
print("下载完成,大小:\(data.count)")
}
} catch {
firstError = error
group.cancelAll()
while let _ = try? await group.next() { }
throw error
}
}
}
还有一种常见错误是直接使用 group.waitForAll() 并期望它抛出任务中的错误。实际上,waitForAll() 只返回是否全部完成,不会重新抛出子任务错误。如果需要捕获错误,必须使用 next() 或 for try await 来逐个获取结果并处理错误。这是很多开发者容易踩的坑。
实战示例:下载任务组的取消清理与错误汇总
下面给出一个稍微完整的实战场景:同时下载多个远程文件,每个子任务负责一个文件,下载完成后写入本地临时目录。父任务如果被取消,子任务需要删除尚未完成的临时文件,已经完成的文件则保留。任何子任务失败时,父任务取消所有剩余任务,并等待它们清理临时文件,最后把错误传播给调用方。
在这个示例中,每个子任务使用 defer 来确保临时文件在出错或取消时被删除,同时通过 try Task.checkCancellation() 在下载循环中响应取消请求。父任务在收集结果时采用前面提到的模式,先取消再继续消耗剩余子任务。
func downloadMultipleFiles(urls: [URL]) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
for url in urls {
group.addTask {
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
defer {
// 子任务退出时删除临时文件
try? FileManager.default.removeItem(at: tempURL)
}
let (bytes, _) = try await URLSession.shared.bytes(from: url)
var data = Data()
for try await byte in bytes {
try Task.checkCancellation()
data.append(byte)
}
try data.write(to: tempURL)
}
}
do {
try await group.waitForAll()
} catch {
group.cancelAll()
while let _ = try? await group.next() { }
throw error
}
}
}
上述代码中 waitForAll() 本身不会抛出子任务错误,所以真正需要捕获错误的地方是通过 for try await 或 next()。但这里为了演示等待全部完成,仍保留了 waitForAll 结构,实际生产中应该改为 for try await _ in group { } 来捕获错误。否则子任务错误会被静默忽略,导致调用方误以为下载全部成功。
修正后的父任务收集阶段如下:使用 for try await _ in group { } 逐个等待子任务结果,一旦某个子任务抛出错误,就进入 catch 分支取消全部剩余任务,再消耗完所有子任务以触发清理。这样既能保证异常正确传播,又能让所有临时文件被删除,不会残留垃圾文件。
func downloadMultipleFilesReliable(urls: [URL]) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
for url in urls {
group.addTask { /* 子任务实现同上 */ }
}
do {
for try await _ in group { }
} catch {
group.cancelAll()
while let _ = try? await group.next() { }
throw error
}
}
}
还需要注意的是,如果父任务本身被取消,而不是子任务主动抛出错误,那么 for try await _ in group 会抛出 CancellationError。此时同样可以按照上述模式先取消所有子任务,再继续消耗它们,最后把 CancellationError 传播出去。这样整个任务树中的清理逻辑都会按顺序执行完毕。
总结来说,TaskGroup 取消后的资源清理与异常传播,核心在于理解协作式取消、善用 defer 和 withTaskCancellationHandler,以及在错误捕获后主动调用 cancelAll 并继续消耗剩余子任务。掌握这些模式后,可以避免大多数 Swift 并发任务组中的资源泄漏和异常丢失问题。
Swift TaskGroup任务取消资源清理修改时间:2026-08-22 21:32:08