正在破坏您的协程(Coroutines)的无声杀手(Silent Killer)

正在破坏您的协程的无声杀手

cancel coroutines

处理 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

薛定谔的协程

cat
可能有一些情况,你确实希望调用 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的消费者任务被取消了?由于有结构化并发的存在,这并不重要:它们会同时被取消。

在仅与自己的子任务交互的协程范围内,较少担心恶意取消。当您与自己的协程范围之外的作业交互,或者手动取消作业时,这些恶意取消问题就会浮现出来。当然,也有可能来自于完全不涉及协程世界之外的其他取消异常。

限制

Jetpack kotlin

截至目前,我尚未发现使用双重检查取消模式存在任何主要问题。一个可能的担忧是,在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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/26204.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

求图的最短路径长度的弗洛伊德(Floyd)算法

弗洛伊德算法的适用情况&#xff1a;弗洛伊德算法既可以用来求解有向网的最短路径长度&#xff0c;也可以用来求无向网的最短路径长度&#xff0c;但是对于图中出现负权环的情况&#xff0c;弗洛伊德无法的得到正确的答案 弗洛伊德的算法思想&#xff1a; 以此图为例讲解弗洛…

从git上拉取项目

目录 一、前期准备&#xff0c;获取git下载链接 二、idea下载 2.1.打开git下载界面 2.2.进入下载界面 2.3.下载前期配置 2.4.输入账号密码 2.5.下载完成后idea打开 2.6.下载完成后文件目录展示 三、命令行下载 3.1.打开所需要下载的项目路径 3.2.进入黑窗口 …

c#快速入门(下)

欢迎来到Cefler的博客&#x1f601; &#x1f54c;博客主页&#xff1a;那个传说中的man的主页 &#x1f3e0;个人专栏&#xff1a;题目解析 &#x1f30e;推荐文章&#xff1a;题目大解析2 目录 &#x1f449;&#x1f3fb;Inline和lambda委托和lambda &#x1f449;&#x1f…

操作系统(2.8)--线程的实现

目录 线程的实现方式 1.内核支持线程(KST) 2.用户级线程(ULT) 3.组合方式 线程的实现 1.内核支持线程的实现 2.用户级线程的实现 线程的创建和终止 线程的实现方式 1.内核支持线程(KST) 内核支持线程&#xff0c;与进程相同&#xff0c;是在内核的支持下运行的&#x…

前端使用tailwindcss 快速实现主题切换方案

使用Tailwind CSS在黑暗模式下为你的网站设计样式。 现在&#xff0c;黑暗模式是许多操作系统的第一流功能&#xff0c;为你的网站设计一个黑暗版本以配合默认设计&#xff0c;变得越来越普遍。 为了使这一点尽可能简单&#xff0c;Tailwind包括一个暗色变体&#xff0c;让你…

JVM之类的初始化与类加载机制

类的初始化 clinit 初始化阶段就是执行类构造器方法clinit的过程。此方法不需定义&#xff0c;是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。构造器方法中指令按语句在源文件中出现的顺序执行。clinit不同于类的构造器。(关联&#xff1a;…

连锁药店系统:如何提高效率和客户满意度?

连锁药店系统是一种用于提高效率和客户满意度的软件系统&#xff0c;它能够管理多个药店的日常营运。通过这种系统&#xff0c;药店可以更好地管理库存、员工、销售和客户信息等方面&#xff0c;从而提高整体的经营效率。 首先&#xff0c;连锁药店系统能够帮助药店管理库存。系…

算法刷题总结 (十一) 二叉树

算法总结11 二叉树 一、二叉树的概念1.1、什么是二叉树&#xff1f;1.2、二叉树的常见类型1.2.1、无数值&#xff08;1&#xff09;、满二叉树&#xff08;2&#xff09;、完全二叉树 1.2.2、有数值&#xff08;3&#xff09;、二叉搜索树&#xff08;4&#xff09;、平衡二叉搜…

数字孪生与物流园区:优化布局规划的关键

随着全球贸易的增长和物流行业的发展&#xff0c;物流园区作为重要的物流枢纽和供应链管理中心&#xff0c;扮演着至关重要的角色。而数字孪生技术的出现为物流园区的运营和管理带来了革命性的变化。数字孪生技术是一种将实体物体与其数字化模型相结合的创新技术&#xff0c;通…

【UEFI】BIOS 阶段全局变量类型

BIOS的几个阶段需要不同阶段的数据传递&#xff0c;下面介绍4个全局变量。 1 固件存储介绍 本规范描述了应该如何在非易失性存储器中存储和访问文件。固件实现必须支持标准的PI固件卷和固件文件系统格式&#xff08;下文所述&#xff09;&#xff0c;但可能支持其他存储格式。…

什么是一致性哈希?一致性哈希是如何工作的?如何设计一致性哈希?

1.什么是一致性哈希&#xff1f;一致性哈希是如何工作的&#xff1f;如何设计一致性哈希&#xff1f;05-25 2.系统设计&#xff1a;从零用户扩展到百万用户05-28 收起 如果你有 n 个缓存服务器&#xff0c;一个常见的负载均衡方式是使用以下的哈希方法&#xff1a; 服务器索…

强连通分量-tarjan算法缩点

一. 什么是强连通分量&#xff1f; 强连通分量&#xff1a;在有向图G中&#xff0c;如果两个顶点u,v间&#xff08;u->v&#xff09;有一条从u到v的有向路径&#xff0c;同时还有一条从v到u的有向路径&#xff0c;则称两个顶点强连通(strongly connected)。如果有向图G的每…

NLP实战:调用Gensim库训练Word2Vec模型

目录 一、准备工作 1. 安装Gensim库 2. 对原始语料分词 二、训练Word2Vec模型 三、模型应用 1.计算词汇相似度 ​编辑 2. 找出不匹配的词汇 3. 计算词汇的词频 四、总结 &#x1f368; 本文为[&#x1f517;365天深度学习训练营]内部限免文章&#xff08;版权归 *K同学…

Flask-RESTful的使用

Flask-RESTful的使用 Flask-RESTful基本使用安装定义资源Resources创建API实例添加资源到API运行Flask应用 请求处理请求解析参数校验 响应处理数据序列化定制返回格式 其他功能蓝图装饰器集合路由命名规范路由名称 Flask-RESTful Flask-RESTful是一个用于构建RESTful API的扩展…

C++类和对象 -- 知识点补充

补充 const成员函数static成员友元内部类匿名对象拷贝对象时的一些编译器优化 const成员函数 将const修饰的成员函数称为const成员函数&#xff0c;const修饰类成员函数&#xff0c;实际是修饰该成员函数隐含的this指针&#xff0c;表明在该成员函数中不能对类的成员进行修改。…

使用MockJS进行前端开发中的数据模拟

在前端开发中&#xff0c;有时我们需要在没有后端接口的情况下进行前端页面的开发和测试。这时&#xff0c;我们可以使用MockJS来模拟数据&#xff0c;以便进行开发和调试。MockJS是一个用于生成随机数据和拦截Ajax请求的JavaScript库&#xff0c;它能够帮助我们快速搭建起一个…

Linux---用户切换命令(su命令、sudo命令、exit命令)

1. su命令 root用户拥有最大的系统操作权限&#xff0c;而普通用户在许多地方的权限是受限的。 普通用户的权限&#xff0c;一般在其HOME目录内是不受限的。 一旦出了HOME目录&#xff0c;大多数地方&#xff0c;普通用户仅有只读和执行权限&#xff0c;无修改权限。 su 是…

【操作系统】01.操作系统概论

操作系统的发展历史 未配置操作系统 手工操作阶段 用户独占全机&#xff0c;人机速度矛盾导致系统资源利用率低 脱机输入输出方式 为了缓解主机cpu和IO设备之间速度不匹配的矛盾&#xff0c;出现了脱机IO技术 在外围机的控制下&#xff0c;通过输入设备&#xff0c;将数据输…

耗时1周整理了网络安全学习路线,非常详细!

前言 这一期就出一个怎么学习网络安全的学习路线和方法&#xff0c;觉得有用的话三连收藏下 首先咱们聊聊&#xff0c;学习网络安全方向通常会有哪些问题 1、打基础时间太长 学基础花费很长时间&#xff0c;光语言都有几门&#xff0c;有些人会倒在学习linux系统及命令的路上…

数论专题(3)逆元

目录 初步认识 逆元 定义 应用 费马小定理 好久没有更新我们的数论专题板块了&#xff0c;今天&#xff0c;我们就来探究一下新知——逆元。 初步认识 在数据非常大的情景下&#xff0c;我们通常会对数据先进行取模运算&#xff0c;来计算在一定的范围内进行处理。而运算…