前言
本篇关于 《Swift协作式任务取消》 的探讨是在 《Swift 结构化与并发》 的前提下展开的对任务取消的操作,对于结构化并发不了解的可以先去看一下结构化并发,很强大的。
协作式取消
在基于回调的派发式并发模型中,取消任务是一件非常困难的事情。并发任务可能会逃逸出当前作用范围,而且并发任务之间缺乏关联,我们往往需要自行维护各个任务之间的关系,持有那些可能被取消的任务,并在适当的情况下将它们停止,这其中涉及的复杂度,其实只是理论上可行,必然存在各种bug。
对于结构化并发,前面我们也介绍过了它的父子任务之间树形层级关系,因为这种层级关系父任务取消可以非常容易地传递到子任务中,这样子任务可以在不持有任务关于父任务的引用的情况下,对取消作出响应 (比如清理资源等)。
不过,结构化并发中的取消的传递,并不意味着任务取消时那些需要手动释放的资源可以被“自动”回收,任务本身在被取消后也不会自动停止。Swift 并发和任务的取消,是一种基于协作式的取消:组成任务层级的各个部分,包括父任务和子任务,往往需要通力合作,才能达到我们最终想要的效果。而结构化并发中取消的传递,仅仅只是协作式取消的一部分。
结构化任务取消初探
让我们先忘掉有关结构化并发的事情,看一个最简单的顶层任务的例子。比如下面的任务中,我们每隔一秒把一个字符追加到结果中:
func work() async -> String {
var s = ""
for c in "Hello" {
// 模拟繁重工作...
await Task.sleep(NSEC_PER_SEC)
print("Append: \(c)")
s.append(c)
}
return s
}
和之前看到的其他例子一样,这里使用了 Task.sleep 来模拟耗时操作。我们创建一个任务,并在其中执行 work,并在一段时间后取消这个任务:
let t = Task {
let value = await work()
print(value)
}
await Task.sleep(UInt64(2.5 * Double(NSEC_PER_SEC)))
t.cancel() // 2.5s
在 2.5s 时,我们调用了 t.cancel() 取消这个任务。但是当我们查看控制台的输出时,可以看到 t 其实执行到了最后:
// 输出:
// Append: H
// Append: e
// Append: l
// Append: l
// Append: o
// Hello”
它似乎并没有按照我们“预想”的那样,在第三次 sleep 时中止。那么疑问是,cancel 方法到底做了什么?我们知道,Task.isCancelled 可以检查当前任务的取消状态,不妨把它加入到输出中看一看:
// print("Append: \(c)")
print("Append: \(c), cancelled: \(Task.isCancelled)")
// 输出:
// Append: H, cancelled: false
// Append: e, cancelled: false
// Append: l, cancelled: true
// Append: l, cancelled: true
// Append: o, cancelled: true
// Hello
在第三次 sleep 结束时,任务的 isCancelled 已经是 true,这说明取消操作确实生效了,但是任务并没有停下来,还是执行到了最后。
实际上,Swift 并发中对某个任务调用 cancel,做的事情只有两件:
-
将自身任务的 isCancelled 标识置为 true。
-
在结构化并发中,如果该任务有子任务,那么取消子任务。
子任务在被取消时,同样做这两件事。在结构化并发中,取消会被传递给任务树中当前任务节点下方的所有子节点。
-
SubTask 1 和 SubTask 2 都是 Root 任务的子任务。如果对 SubTask 1 调用 cancel(),SubTask 1 的 isCancelled 被标记为 true。
-
接下来取消被传递给 SubTask 1 的所有子任务,它们的 isCancelled 也被标记为 true。
-
取消操作在结构化任务树中一直向下传递,直到最末端的叶子节点。 cancel() 调用只负责维护一个布尔变量,仅此而已。它不会涉及其他任何事情:任务不会因为被取消而强制停止,也不会让自己提早返回。这也是为什么我们把 Swift 并发中的取消叫做“协作式取消”的原因:各个任务需要合作,才能达到最终停止执行的目标。父任务要做的工作就是向子任务传递 isCancelled,并将自身的 isCancelled 状态设置为 true。当父任务已经完成它自己的工作后,接下来的事情就要交给各个子任务的实现,它们要负责检查 isCancelled 并作出合适的响应。换言之,如果谁都没有检查 isCancelled 的话,协作式的取消就不成立了,整个任务层级向外将呈现出根本不支持取消操作的状态。这就是为什么在我们的例子中任务一直执行到了最后的原因。
结构化任务取消方案
- 在 work 的实现中,我们使用了 try Task.checkCancellation() 检测任务的取消情况,并抛出 CancellationError 错误。Task 1.1 或 Task 1.2 中的这部分代码将被触发,并将错误抛给 SubTask 1。
func work(_ text: String) async throws -> String {
var s = ""
for c in text {
if Task.isCancelled {
print("Cancelled: \(text)")
}
try Task.checkCancellation()
await Task.sleep(NSEC_PER_SEC)
print("Append: \(c)")
s.append(c)
}
print("Done: \(s)")
return s
}
-
这个错误并没有在 SubTask.addTask 中被处理,于是它将被进一步抛出到上层,也就是 Root 中。
-
作为父任务,外层 Root 在接受到 SubTask 1 的错误后,会主动取消掉任务树中所有的子任务,等待子任务们全部执行完毕 (不论是正常返回还是抛出错误) 后,再进行错误处理。在这里,Root 中除了 SubTask 1 外,只有一个其他子任务 SubTask 2。于是,SubTask 2 的 isCancelled 也被置为 true,并触发 work 中的相关检查抛出取消错误。
处理任务取消
现在让我们来看看在任务中要如何实际利用 isCancelled 来停止异步任务。结构化并发要求异步函数的执行不超过任务作用域,因此在遇到任务取消时,如果我们想要进行处理并提前结束任务,大致只有两类选择:
-
提前返回一个空值或者部分已经计算出来的值,让当前任务正常结束。
-
通过抛出错误并汇报给父层级任务,让当前任务异常结束。
我们分别来看看这两种处理方式。
返回空值或部分值
当任务的取消不影响流程,我们可以通过提前返回空值或者部分值,来完成当前任务。通过检查 Task.isCancelled,我们可以做到这一点。比如将上面的 work 改写为:
func work() async -> String {
var s = ""
for c in "Hello" {
// 检查取消状态
guard !Task.isCancelled else { return s }
await Task.sleep(NSEC_PER_SEC)
print("Append: \(c)")
s.append(c)
}
return s
}
func start() async {
let t = Task {
let value = await work()
print(value)
}
await Task.sleep(UInt64(2.5 * Double(NSEC_PER_SEC)))
t.cancel()
}
// 输出:
// Append: H
// Append: e
// Append: l
// Hel
抛出错误
如果某个任务的完成情况 (或者说,返回值) 在并发操作中具有关键作用,其他任务必须依赖该任务确实完成才能继续进行的话,返回空值或者部分值就不再是一个可行的选项了。
举个例子,比如我们正在实现一个图片下载和缓存的框架,大体上有三个步骤:
首先我们从网络下载图片数据
然后把这个数据缓存到磁盘
最后将图片本身提供给框架的调用者
这三个任务:下载数据、缓存数据以及提供图片,其重要程度并非对等。缓存任务和提供图片的任务是依赖于下载任务的:只有当下载数据确实完整,缓存和提供图片才有意义。但是提供图片的任务并不依赖于缓存任务:即使缓存失败了,也可以从下载的数据中生成图片。因此,在设计这些任务时,当缓存任务被取消时,我们可以选择返回部分结果或者 nil;但是当下载任务被取消时,我们只能抛出错误,告诉框架调用者任务无法完成。
内建 API 的取消
你可能会觉得有点儿麻烦,因为在设计并发系统时,如果我们想要尽快地响应取消,则需要在每个 await 前后添加 try Task.checkCancellation()。虽然这并不困难,但是显然是一种重复劳动和模板代码。
在上例中,Task.sleep(_:) 本身并不支持取消:它会忠实地计数到设定的时间后再将控制流交还。不过,Swift 并发在 Task 的 API 中还提供了一个可以取消的 sleep 版本,它接受一个命名参数 nanoseconds,并被标记为 throws,以示区别:
extension Task where Success == Never, Failure == Never {
static func sleep(nanoseconds duration: UInt64) async throws
}
在遇到取消时,sleep(nanoseconds:) 会直接中断,并抛出 CancellationError。如果我们使用这个版本的 sleep 来改写 work,则可以不再手动进行 checkCancellation:
func work(_ text: String) async throws -> String {
var s = ""
for c in text {
if Task.isCancelled {
print("Cancelled: \(text)")
}
// try Task.checkCancellation()
// await Task.sleep(NSEC_PER_SEC)
try await Task.sleep(nanoseconds: NSEC_PER_SEC)
s.append(c)
// ...
}
// 输出为:
// Append: S
// Append: H
// Append: W
// Append: w
// Append: o
// Append: e
// CancellationError()
对比 sleep(_:) 中每次在 await 前进行检查的版本,sleep(nanoseconds:) 在抛出错误时更加及时,它不需要等到当前的 await 结束后再进行抛出。相比于原来的处理取消的方式,sleep(nanoseconds:) 是更优秀的实现。
取消的清理工作
defer: 确保代码在离开作用域后进行调用
func load(url: URL) async throws {
let started = url.startAccessingSecurityScopedResource()
if started {
try Task.checkCancellation()
await doSomething(url)
try Task.checkCancellation()
await doAnotherThing(url)
// 调用可能没有被执行到
url.stopAccessingSecurityScopedResource()
}
}
在同步的世界中,为了避免在各个退出路径上重复写清理代码,我们往往使用 defer 来确保代码在离开作用域后进行调用。这个技巧在异步操作中也是适用的,在上面的代码中,我们只需要在 if started 内加上 defer,就可以应对取消时的资源清理工作了:
func load(url: URL) async throws {
let started = url.startAccessingSecurityScopedResource()
if started {
defer {
url.stopAccessingSecurityScopedResource()
}
await doSomething(url)
try Task.checkCancellation()
await doAnotherThing(url)
try Task.checkCancellation()
}
}
Cancellation Handler
在使用 defer 时,只有在异步操作返回或者抛出时,defer 才会被触发。如果我们使用 checkCancellation 在每次 await 时检查取消的话,实际上抛出错误的时机会比任务被取消的时机要晚一些:在异步函数执行暂停期间的取消,并不会立即导致抛出,只有在下一次调用 checkCancellation 进行检查时,才进行抛出并触发 defer 进行资源清理。虽然在大部分情况下,这一点时间差应该不会带来问题,但是对于下面两种情况,我们可能会希望有一种更加“实时”的方法来处理取消。
unc asyncObserve() async throws -> String {
let observer = Observer()
return try await withTaskCancellationHandler {
observer.start()
return try await withUnsafeThrowingContinuation {
continuation in
observer.waitForNextValue { value, error in
if let value = value {
continuation.resume(returning: value)
} else {
continuation.resume(throwing: error!)
}
}
}
} onCancel: {
// 取消时清理资源
observer.stop()
}
}
如果没有 withTaskCancellationHandler,我们在封装这种带有“取消”功能的异步操作时,将不得不以轮询的方式,在 continuation.resume 之前去不断检查 Task.isCancelled,这会让取消变得不及时,甚至导致如果新的事件不发生的话,持有的资源就永远无法释放。相比起来,onCancel 给了我们更加正确和优雅的解决方式。
异步序列的取消
异步序列协议最重要的部分,就是 AsyncIterator 所定义的 next() async throws 函数。这个函数已经被标记为 throws 了,因此和其他的异步操作一样,我们可以选择在实现 next() 时检查任务是否已经取消,并抛出相应的错误:
mutating func next() async throws -> Int? {
try Task.checkCancellation()
return try await getNextNumber()
}
这样,当通过 for try await 运行的异步序列的 Task 被取消时,在序列中计算下一个元素时,序列将会抛出并终结。
隐式等待和任务暂停
结构化并发的潜在暂停点
在介绍异步函数时,我们提到过,await 代表潜在暂停点。我们需要特别注意在 await 前后,异步函数的执行上下文可能发生变化,这包括任务的取消状态。因此,如果我们选择使用 isCancelled 或 checkCancellation 检查任务取消的话,await 会是一个很好的标志:在 await 前后对任务的取消状态进行检查,是一种省心省力的做法。
不过,在结构化并发中,会存在隐式 await 的情况。我们在前面已经说过,在 TaskGroup 中,如果我们没有明确地等待 group 中的任务,它们将会在离开 group 作用域前被隐式等待:
let t = Task {
do {
try await withThrowingTaskGroup(of: Int.self) { group in
group.addTask { try await work() }
group.addTask { try await work() }
}
} catch {
print("Error: \(error)")
}
}
await Task.sleep(NSEC_PER_SEC)
t.cancel()
func work() async throws -> Int {
try await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC)
print("Done")
return 1
}
运行上面的代码,你既看不到 work 中的 “Done” 输出,也看不到 catch 块中 “Error” 的输出。这是因为我们没有明确对 group 进行 try await 操作。try await work 只生存在 addTask 内,它的抛出会向上传递到 group 中,但由于我们没有明确地 try await group,这个错误并不会继续传递到 withThrowingTaskGroup 的外层。在离开作用域时的隐式等待,会选择自行消化这个错误,而不是进行抛出,这一点并不是特别明显。上面这样看似覆盖完整的代码分支却两边都不执行,这种情况非常难以进行调试和理解。
没有完整 await 的 group 所面临的假设和情况,要比完整写出 await 的时候复杂得多。所以笔者建议,不论我们最终需不需要子任务的返回值,都应该保持明确写出对 group 等待操作的好习惯。比如,在离开作用域时补上 try await,就可以让 catch 代码块在接收到取消时正确工作:
do {
try await withThrowingTaskGroup(of: Int.self) {
group in
group.addTask { try await work() }
group.addTask { try await work() }
try await group.waitForAll()
} catch {
print("Error: \(error)")
}
}
小结
协作式的任务取消是 Swift 并发中重要的一环。相比于传统的并发模型,在处理“正常路径”时,也许结构化并发的优势并没有那么明显,但是在处理错误或者取消时,取消标记在任务层级树中的传递以及检查,可以帮助我们轻而易举地写出正确、稳定和高效的复杂并发代码。这在以前的传统并发时代是难以想象的。
不过,在使用协作式取消这一新工具时,我们也需要承担更多的责任。如果要实现自己的并发系统,我们需要确保异步任务能够正确处理协作式取消。只有这样,我们的并发系统才能符合 Swift 并发体系的规范和要求。这在别人使用我们创建的并发系统,以及把这个系统集成到别的并发系统中时,是十分关键的。
在任务中抛出错误或者处理取消,意味着我们会提前退出任务上下文。这为我们带来了另一个重要的话题:资源清理。结构化并发保证了并发操作的生命周期不会超过函数作用域,这为资源清理带来了巨大的便利,我们可以确保在退出任务时,没有任何子任务还在运行并需要这些资源。得益于异步函数的特点和结构化并发模型对异步操作生命周期的规定,我们可以用与处理同步函数类似的方式 (比如 defer),把原本需要分散在各个地方的清理代码进行统合。这进一步降低了创建并发系统的难度,也减少了在程序中不小心写出 bug 的可能性。
写在最后
本文大部分内容引自喵神 《Swift 异步与并发编程》,最终版权归原作者所有,若有侵权的地方,烦请告知。