在runBlocking中,deferred.await()抛出的异常即使被捕获仍被视为未处理异常

14

这段代码:

fun main() {
    runBlocking {
        try {
            val deferred = async { throw Exception() }
            deferred.await()
        } catch (e: Exception) {
            println("Caught $e")
        }
    }
    println("Completed")
}

这会导致以下输出结果:

Caught java.lang.Exception
Exception in thread "main" java.lang.Exception
    at org.mtopol.TestKt$main$1$deferred$1.invokeSuspend(test.kt:11)
    ...

这种行为对我来说没有意义。异常已被捕获和处理,但仍然作为未处理的异常逃逸到顶层。

这种行为是否有文档记录并且是期望的?它违反了我对异常处理应该工作的所有直觉。

我从Kotlin论坛的一个帖子中改编了这个问题。


Kotlin文档建议使用supervisorScope,如果我们不想在一个协程失败时取消所有协程。因此我可以这样写

fun main() {
    runBlocking {
        supervisorScope {
            try {
                launch {
                    delay(1000)
                    println("Done after delay")
                }
                val job = launch {
                    throw Exception()
                }
                job.join()
            } catch (e: Exception) {
                println("Caught $e")
            }
        }
    }
    println("Completed")
}

现在的输出是{{output}}。
Exception in thread "main" java.lang.Exception
    at org.mtopol.TestKt$main$2$1$job$1.invokeSuspend(test.kt:16)
    ...
    at org.mtopol.TestKt.main(test.kt:8)
    ...

Done after delay
Completed

这不是我想要的行为。在此,一个已启动的协程由于未处理的异常而失败,从而使其他协程的工作失效,但它们仍会继续运行。
我认为合理的行为是,在协程以无法预料(即未处理)的方式失败时,应该传播取消。从await中捕获异常意味着没有全局错误,只有作为业务逻辑的一部分处理的局部异常。

当我运行你的代码时,我得到了这个错误:Caught java.lang.Exception Completed... 请问你使用的是哪个版本? - Roland
当前版本为Kotlin 1.3,带有kotlinx.coroutines 1.0。 - Marko Topolnik
1
async中处理异常可能不适合使用情况。也许我想要在此期间获取一些其他的异步结果,作为能够处理异常的先决条件。async调用也可能是一个不受我控制的库的一部分。 - Marko Topolnik
但是使用异常处理程序在这里也可以工作,对吧?你可以抛出异常并在处理程序中处理它... - Roland
1
完全同意这个问题。虽然捕获所有未等待或未加入的协程中的错误似乎很有用,但如果它被等待,最好让错误直接冒泡。希望做出这个设计决策的人能够阐明一些问题。 - Jonas Wilms
显示剩余2条评论
4个回答

9

在研究Kotlin引入此行为的原因后,我发现如果异常不是以这种方式传播的话,编写及时取消代码将变得复杂。例如:

runBlocking {
    val deferredA = async {
        Thread.sleep(10_000)
        println("Done after delay")
        1
    }
    val deferredB = async<Int> { throw Exception() }
    println(deferredA.await() + deferredB.await())
}

因为 a 是我们等待的第一个结果,所以这段代码将会持续运行 10 秒钟,最终导致错误且没有任何有用的工作完成。在大多数情况下,我们希望一旦其中一个组件失败就取消所有操作。我们可以像这样实现:

val (a, b) = awaitAll(deferredA, deferredB)
println(a + b)

这段代码不够优美:我们被迫在同一处等待所有结果,并且失去了类型安全,因为 awaitAll 返回所有参数的共同超类型的列表。如果我们有一些...
suspend fun suspendFun(): Int {
    delay(10_000)
    return 2
}

我们希望编写:

val c = suspendFun()
val (a, b) = awaitAll(deferredA, deferredB)
println(a + b + c)

suspendFun 完成之前,我们无法退出。我们可以像这样解决:

val deferredC = async { suspendFun() }
val (a, b, c) = awaitAll(deferredA, deferredB, deferredC)
println(a + b + c)

但这种方式非常脆弱,因为你必须小心确保每个可暂停的调用都需要这样做。 这也违背了Kotlin "默认顺序执行" 的原则。
总之: 当前的设计虽然一开始有点难以理解,但实际上它是一个实用的解决方案。 它还加强了规则:除非你正在对任务进行并行分解,否则不要使用 async-await

2
虽然所有答案都在其位置,但让我为其他用户提供更多的解释。据记录这里官方文档):
如果协程遇到除了CancellationException之外的异常,它会使用该异常取消其父协程。这种行为无法被覆盖,并用于为结构化并发提供稳定的协程层次结构,而不依赖于CoroutineExceptionHandler的实现。当所有子协程终止时,原始异常将由父协程(在GlobalScope中)处理。

在主runBlocking范围内启动的协程安装异常处理程序是没有意义的,因为即使已安装处理程序,主协程仍将在其子协程以异常完成时被取消。


2
另一方面,文档提示 async 异常不会传播到其父级。我认为这是误导性的。 - Yamashiro Rion
@YamashiroRion 我完全同意。捕获然后同时取消父级?他们绝对应该在文档中提到这种古怪的行为。 - funct7

1

这可以通过稍微修改代码来解决,使deferred值在使用与runBlocking作用域相同的CoroutineContext时显式执行,例如:

runBlocking {
    try {
        val deferred = withContext(this.coroutineContext) {
            async {
                throw Exception()
            }
        }
        deferred.await()
    } catch (e: Exception) {
        println("Caught $e")
    }
}
println("Completed")

在问题更新后进行更新

这是否提供了您需要的内容:

runBlocking {
    supervisorScope {
        try {
            val a = async {
                delay(1000)
                println("Done after delay")
            }
            val b = async { throw Exception() }
            awaitAll(a, b)
        } catch (e: Exception) {
            println("Caught $e")
            // Optional next line, depending on whether you want the async with the delay in it to be cancelled.
            coroutineContext.cancelChildren()
        }
    }
}

这是从this评论中提取的内容,讨论了并行分解。

是的,在发布之前我尝试过这种变化。将async-await对替换为只有withContext也可以恢复预期的行为。 - Marko Topolnik
这在 kotlinx.coroutines 的 GitHub 存储库的问题[753]和[763]中有详细讨论。我和@marstran提出的建议都是解决此问题的方法。有趣的是,他们也将其称为“async的惊人行为”:-) 763的结果是记录了一个问题以更好地记录此行为,即“async.await取消外部范围”(问题[787])。 - Yoni Gibbs
是的,这可以简化为 try { awaitAll(a, b) } catch (e: Exception) { coroutineContext.cancelChildren() }。这是一个解决方法,首先禁用默认行为,然后重新实现自己的取消传播。它强调了默认行为的问题,即自动在后台运行此 try awaitAll - cancelChildren 习惯用语,而不考虑我的处理逻辑。 - Marko Topolnik

0

一个普通的CoroutineScope(由runBlocking创建)在其中一个子协程抛出异常时会立即取消所有子协程。这种行为在此处有文档记录:https://kotlinlang.org/docs/reference/coroutines/exception-handling.html#cancellation-and-exceptions

您可以使用supervisorScope来获得所需的行为。如果在supervisor作用域内发生子协程失败,它不会立即取消其他子协程。只有当异常未处理时,才会取消子协程。

更多信息,请参见此处:https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/supervisor-scope.html

fun main() {
    runBlocking {
        supervisorScope {
            try {
                val deferred = async { throw Exception() }
                deferred.await()
            } catch (e: Exception) {
                println("Caught $e")
            }
        }
    }
    println("Completed")
}

1
我实际上不希望协程在其中一个抛出异常时不被取消。我期望所有的协程都因为其中一个协程有未处理的异常而被取消。这种行为,即我明确处理了异常但它仍然逃逸到了顶层,似乎是有问题的。 - Marko Topolnik
如果未处理异常,supervisorScope 将取消所有子作业。我会编辑文本以使其更清晰 ;) - marstran
1
只有当supervisorScope块抛出异常时,它才会取消子级,但如果异常从嵌套的launch块或任何其他启动另一个协程的块中逃逸未经处理,则不会执行任何操作。 - Marko Topolnik
我将其作为问题的补充提供。 - Marko Topolnik
更广泛地说,这是我认为会出现的行为。在协程以未明确处理的方式失败时传播取消。这是并行分解所需的,而不仅仅是任意的异常破坏整个设置。捕获和处理异常不算是一种失败。 - Marko Topolnik
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接