正在破坏您的协程的无声杀手
处理 Kotlin 中的取消异常的唯一安全方法是不重新抛出它们。
今天生产服务器再次停止响应流量。
上个星期,你刚重新启动它们并将其视为故障。但是你总觉得有些奇怪,因为日志中没有任何错误的痕迹,甚至没有警告。准确说,这一次的模式完全相同。
逐个地,服务器进入了僵尸模式。虽然灯是亮着的,但是没有人在家。
听起来像是噩梦般的场景,对吧?但这正是 Kotlin 协程在取消异常失控时会发生的事情。而如果取消异常无处不在,这种情况发生的机率比你想象的要高。像调用已取消任务的 await
这样简单的操作,也可能会导致反直觉和有害的结果。
幸运的是,有一种解决方案,但它几乎完全相反于你可能听说过的方案。简而言之,让协程完全安全的唯一方法是捕获每个取消异常并不重新抛出它。相反,使用 ensureActive
手动检查取消操作。
不,我保证我没有完全失去思路。让我解释一下。
在 Kotlin 中的取消异常有一个危险的超能力。当一个协程以 CancellationException
结束时,它不会被视为错误。而是会默默地结束协程,就像没有任何异常一样。
launch {
throw CancellationException()
}
这与其他异常的处理方式非常不同。如果以任何其他类型的未捕获错误结束协程,则该异常将向上传播到作业层次结构,最终导致协程范围或应用程序崩溃,如果错误未被捕获和处理。
launch {
throw Exception("Something went wrong!")
}
取消异常处理方式的原因显然是它们作为协程取消流程的一部分使用。它们以两种方式被内置于协程机制中。
协程被取消后,尝试调用类似于 delay 或 await 的内置挂起函数将会立即抛出一个 CancellationException
,这有助于确保协程被通知它已被取消,不会继续执行工作。
任何时候,当协程以 CancellationException
结束时,该异常不会向上级任务层次结构中传播。其父任务将不会收到异常通知。这允许使用取消异常快速、干净地退出已取消的协程,同时不触发错误处理行为。
只有在取消异常在不被首先捕获的情况下,才能执行其作业并终止协程的任务调用堆栈的顶部的位置。因此,你可能经常听到的建议是不应该捕获取消异常,或者应该始终重新抛出它们。如果反复捕获并忽略已取消的协程将生成的取消异常,则协程可能会进入一种僵尸状态。它仍在运行,可能还持有锁或资源,但除了不断抛出和捕获取消异常外,它可能什么有用的工作也不做。
因此,在需要执行全局错误处理的协程中,通常会看到一个特殊的情况,它会查找取消异常并确保重新抛出它们。
launch {
try {
doSomething()
} catch (error: Throwable) {
if (error is CancellationException) {
throw error // ensure co-operative cancellation
} else {
handleError(error)
}
}
}
流氓取消
盲目地重新抛出每个取消异常确实避免了一个问题,但在我看来,它打开了一个危险的新威胁。
请记住,协程对于取消异常有特殊处理,将其视为普通终止而不是错误。好吧,这种特殊处理不仅在协程实际取消时才会启动。它一直处于活动状态。
结果是,在正常活动协程中的代码中抛出的取消异常仍然可能导致协程静默终止。
如果取消异常只能由协程取消引起,那可能不是一个问题。但实际上,取消异常出现的原因非常广泛。我称在活动的非取消协程中抛出的取消异常为“流氓取消异常”。调用取消任务上的await时抛出的取消异常可能是最常见的例子,但随着我进一步描述,我们也会发现几个其他的例子。
在我之前确定协程崩溃的原因之前,我的代码完全符合杀手的意图。
流氓取消异常很危险,因为它可以不触发任何错误处理行为或日志记录而在不被察觉的情况下,静默地杀死一个协程,事实上,错误处理代码通常会故意忽略取消异常。在我确定崩溃协程的原因之前,我的许多代码都会因排除取消异常的日志而完全符合杀手的意图。
流氓取消有两个非常严重的问题。首先,协程在执行过程中突然而又悄无声息地消失,即使没有人要求停止它。其次,如果这个异常不是由当前协程的正常取消所导致,那么它必然是由其他问题所引起的,而这些问题现在将完全不被察觉。
这些问题一起出现时,很容易在介绍中创建“僵尸应用程序”场景,其中应用程序似乎在运行,但是执行关键后台工作或消息处理的协程却已经消失,而没有任何错误的迹象。
我接下来将谈论一些流氓取消异常可能出现的地方。但首先,让我们看看如何保护你的协程免受它们的威胁。
Double-Checked Cancellation(双重检查取消)
如何区分真正的取消和错误的取消异常?CancellationException
本身没有任何公共属性或状态,您无法检查其来自何处,JobCancellationException
子类也不公开。
我的解决方案是我称之为“双重检查取消”的模式。我以前没有在任何地方见过这种模式,因此我声称是我想出的。如果您之前见过,请告诉我。请随意使用并为您自己的代码进行调整!
这个想法很简单:每当您看到取消异常时,请检查协同程序是否实际上已被取消。
try {
doStuff()
} catch (error: Throwable) {
coroutineContext.ensureActive()
handleError(error)
}
suspend fun doStuff1() {
cancelCurrentJob()
}
suspend fun doStuff2() {
throw CancellationException()
}
launch {
try {
doStuff1()
} catch (error: Throwable) {
coroutineContext.ensureActive()
handleError(error)
}
}
如果在try段中抛出取消异常,那么显式调用ensureActive
就构成了第二个检查取消的步骤。如果当前作业真的被取消了,第二个检查将立即抛出另一个取消异常,跳出catch块。
suspend fun doStuff1() {
cancelCurrentJob()
}
suspend fun doStuff2() {
throw CancellationException()
}
launch {
try {
doStuff1()
} catch (error: Throwable) {
coroutineContext.ensureActive()
handleError(error)
}
}
为了了解它的工作原理,请尝试更改可运行示例,使其调用doStuff2
而不是doStuff1
。其中一个函数可以正确地取消作业,而另一个则会抛出异常。双重检查取消模式可以准确解决两者之间的歧义,让您在处理真正的取消的同时处理异常情况。
双重检查取消模式非常适合捕获所有错误处理,您需要处理广泛类别的错误,而不会干扰适当的协程取消。使用双重检查取消,您可以捕获您喜欢的任何错误,并在catch
块的顶部包含一次对ensureActive
的调用。如果协程被取消,取消异常将逃逸。就像重新抛出异常一样,只是更容易,不会冒着传播可能默默掩盖真正问题的“流氓”取消异常的风险。
try {
handleRequest()
} catch (error: Throwable) {
coroutineContext.ensureActive()
log.error("Unexpected error while handling request", error)
}
Cancellation Exceptions
的双重生活
我们真的需要这么费劲地避免这些流氓取消异常吗?当然,它们听起来很糟糕,但它们真的有多常见吗?为什么我们会期望在实际上并没有被取消的协程中抛出或捕获CancellationException
呢?
事实证明,流氓取消异常几乎无处不在。我已经涉及过一个常见的例子。考虑一个异步协程,它以Deferred
结果的形式产生一个值。我们从文档中知道,调用await将返回成功的值,或者如果作业不成功,则抛出相应的异常。基于此,您可能能够猜到,如果取消了异步作业,await
将抛出CancellationException
。
表面上看,这似乎与我们在取消作业中获得的取消异常并没有太大不同。然而,有一个重要的区别:被取消的协程不是做出挂起调用并接收异常的协程。实际上,您可以有一个已取消的Deferred
,它会抛出CancellationException
,而没有任何支持它的协程。
val deferred = CompletableDeferred<String>()
deferred.cancel()
try {
println(deferred.await())
} catch (e: CancellationException) {
println(e)
}
如果没有捕获这个恶意取消异常,它有潜力静默地终止调用协程,即使在此应用程序中没有取消协程。
这意味着有一整个新的取消异常类别不一定与协程取消相对应。当你在取消通道的接收方已取消时调用发送或接收时,取消的Deferred抛出的异常也远非唯一。但实际上,这个故事始于Java。自Java 1.5中java.util.concurrent.CancellationException
的添加开始。文档描述了新异常,表示“指示无法检索产生值的任务的结果,因为任务已取消。”
请注意这与Kotlin的job取消异常之间的区别。如果在挂起时取消协程,可取消挂起函数抛出这些异常。在协程内部,Kotlin样式取消异常被抛出以中断正在进行的工作,作为取消工作的正常终止的一部分。Java不使用CancellationException
来实现这个目的——它使用InterruptedException
。Java风格的取消异常不是用于中断工作,而是用于在尝试访问从未生成值的任务的结果时发出非法状态的信号。
当我们查看JVM上Kotlin的CancellationException
定义时,我们可以看到它实际上是原始java.util.concurrent.CancellationException
的类型别名。这立即为在JVM上运行并与Java代码交互的Kotlin应用程序引入了问题。不用说,从Java类抛出的CancellationException
不太可能与协程取消相关,应将其视为某种错误的指示。但是,如果失败的代码恰好在Kotlin协程中运行,该异常很可能只会导致协程静默终止,而不是触发适当的错误处理。
Kotlin的CancellationException
似乎正在过着双重生活。一方面,它从Java继承了一些语义,即在尝试与已取消的内容交互时抛出的IllegalStateException
。当我们在取消Deferred时调用await时,这更接近于我们所看到的情况。另一方面,在协程内部,CancellationException
具有特殊的含义,它不被视为错误,而是用于打破控制流并提前结束工作。
根据它们的特性,等待另一个协程结果的函数是异步的。而内建的可挂起函数通常包含对当前协程的自动取消检查。这意味着像await、send和receive
之类的函数可能出于两个完全不同的原因抛出取消异常。一种情况是函数的调用者本身已被取消。另一种情况是函数希望表明其正常返回值或行为不可用,因为接收方处于取消状态。
像 “await” 这样的函数可能会因为两个完全不同的原因而抛出取消异常。
Kotlin 协程的未来版本有可能会帮助解决这种歧义。例如,目前已经提出了一项更改建议,将改变标准 withTimeout
函数抛出的异常,使其不再继承自 CancellationException
。
薛定谔的协程
可能有一些情况,你确实希望调用 await
的呼叫会导致当前的协程默默地终止。假设一个 “consumer” 在处理来自第二个 “producer” 协程的值,如果 “producer” 不再工作,消费者也关停,这样可能是有意义的,而不是将其视为错误。这显然似乎是许多这些标准函数的内置假设。但是,这是错误的假设,有两个原因。首先,在不同情况下,您可能有许多合法的原因要选择将缺失的值视为错误。其次,可能更重要的是,抛出取消异常并不会取消协程。
协程的 “active state” 是由其 “Job” 跟踪的,并且只有当 “Job” 表示它被取消时,协程才会被取消。如果您只是抛出取消异常,然后再次捕获它,协程仍然处于活动状态,可以像正常情况下一样继续运行。这与调用取消的情况不同,取消会将 “Job” 标记为非活动状态,并防止协程再次挂起。
逃逸到尚未被取消的协程中的取消异常始终是异常情况。如果它传播到协程的顶部并导致协程终止,它可能最终会将协程标记为已取消。但是,与真正的取消不同, “rogue cancellation” 异常是可恢复的状态。如果你捕获并忽略了一个 “rogue cancellation” 异常,工作仍然处于活动状态,并可以像什么都没有发生一样继续。如果您尝试捕获并忽略由实际协程取消引起的取消异常,协程仍然会记住它已被取消,如果它尝试继续运行,它几乎肯定会很快遇到另一个取消异常。协程无法取消。
与真正的取消不同, “rogue cancellation” 异常是可恢复的状态。
我认为,遇到 “rogue cancellation” 异常的协程存在于类似于取消状态的一个"quantum superposition"中。如果你只是重新抛出每个取消异常,你选择不看里面。这的影响是重大的,很难预测,并且取决于异常下一步走向的地方。
不要让你的协程走上薛定谔的猫的道路。如果您使用双重检查取消模式捕获并识别 “rogue CancellationException”,请立即决定您的协程是死亡还是活着。如果您认为当前协程应该被取消,则通过调用 cancel
来正确取消,这样 job 标记为取消,并且所有未来代码都可以正确检测和处理取消。如果您认为协程应该失败,请使用某种其他类型的异常包装或替换 “CancellationException”,以便它永远不会被误解为正常取消。如果您想要协程继续运行,请处理异常并不要重新抛出它。这三个都是针对 “rogue cancellation” 异常的有效响应,具体取决于您的特定应用程序。
结构化并发
关于协程取消的讨论,没有提到结构化并发是不完整的。取消是结构化作业层次结构的关键部分,因为它允许被终止的作业及时安全地丢弃其不需要的子协程。由于结构化并发,一个协程的取消或失败通常会导致其他作业的自动取消。
今天在 Kotlin 协程中遇到的许多冒牌取消异常实际上早于结构化并发。我猜这是早期提供依赖相互的作业实现一定程度上的自动取消的遗物。等待已取消的作业的结果?您可能会收到取消异常。这是一种非正式和粗略的关系,现在已被一种更正式的链接作业系统所取代。如今,一起工作的两个协程很可能由共同的父级管理。在这种情况下,任何导致其中一个协程取消的原因也很可能导致两个协程的取消。
val job = launch {
val greeting = async {
delay(1000)
"Hello, World!"
}
println(greeting.await())
}
delay(100)
job.cancel()
在示例中,调用greeting.await()
会抛出取消异常,是因为异步的greeting生产者任务被取消了,还是因为调用await
的消费者任务被取消了?由于有结构化并发的存在,这并不重要:它们会同时被取消。
在仅与自己的子任务交互的协程范围内,较少担心恶意取消。当您与自己的协程范围之外的作业交互,或者手动取消作业时,这些恶意取消问题就会浮现出来。当然,也有可能来自于完全不涉及协程世界之外的其他取消异常。
限制
截至目前,我尚未发现使用双重检查取消模式存在任何主要问题。一个可能的担忧是,在catch块中调用ensureActive
可能通过抛出新的CancellationException
来抑制原始异常。这只会在取消后的协程中才会发生,所以这并不是一个致命问题,但值得注意。我仍然认为这比让每个协程容易受到默默的暗杀更可取。
另一个最初可能看起来限制性的事情是,你只能从暂停函数中调用ensureActive
。正如我希望现在已经展示的那样,在暂停函数之外,你可能会遭遇CancellationException
,其方法不只一种。
幸运的是,这个问题只是表面,你只能通过检查是否当前作业正在运行的函数遇到真正的作业取消异常,只有暂停函数才能访问当前工作。这意味着如果你在非暂停上下文中捕获取消异常,即使在协程中,你也知道它是一个不正确的取消异常,并且应该始终将其视为错误,所以不需要使用双重检查取消。
当涉及到flows时,此模式还有一个小限制。双重检查取消假定仅当取消异常与协程作业取消相对应时,才应该悄悄地处理取消异常。但作为从流中提前退出的内部使用的silent AbortFlowException
也是CancellationException
的子类。流只是一种控制结构,而不是协程,因此它没有工作可取消。从流中提前退出类似于从挂起函数中提前返回-它不会取消任何协程。
表面上看,这意味着双重检查取消会错误地将流取消标识为错误。但在实际代码中,这不应该是一个问题。流将抛出AbortFlowException
的唯一位置是其emit
函数。捕获来自emit
的异常始终是不正确的,因为它们实际上属于流下游的消费者,应该使用catch操作符进行处理。因此,双重检查取消无法处理从emit
中捕获的错误,但你也不应该在那里捕获错误。
结论
取消例外不应被忽略或抑制,因为这可能导致取消的协程进入僵尸状态,无法终止也无法执行任何工作。但是,重新抛出每个取消例外的做法也是有缺陷的,这可能会导致重要的协程静默消失而没有正确地处理错误。
问题在于取消例外有多个常见源,安全地处理它们需要能够区分它们。双重检查取消模式在捕获异常后使用ensureActive
,允许您将流氓取消处理为错误,同时让真实的取消例外正确传播。
参考
https://betterprogramming.pub/the-silent-killer-thats-crashing-your-coroutines-9171d1e8f79b