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

Exception thrown by deferred.await() within a runBlocking treated as unhandled even after caught

此代码:

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$deferred.invokeSuspend(test.kt:11)
    ...

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

这种行为是否记录在案并且符合预期?它违反了我对异常处理应该如何工作的所有直觉。

我从 Kotlin forum 上的一个线程改编了这个问题。


如果我们不想在一个协程失败时取消所有协程,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")
}

现在输出

Exception in thread "main" java.lang.Exception
    at org.mtopol.TestKt$main$job.invokeSuspend(test.kt:16)
    ...
    at org.mtopol.TestKt.main(test.kt:8)
    ...

Done after delay
Completed

这又不是我想要的行为。这里一个 launched 协程因未处理的异常而失败,使其他协程的工作无效,但它们继续不间断。

我认为合理的行为是当协程以不可预见的(即未处理的)方式失败时分散取消。从 await 捕获异常意味着没有任何全局错误,只是作为业务逻辑的一部分处理的本地化异常。

这可以通过稍微改变代码来解决,使 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 讨论并行分解的评论。

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

您可以使用 supervisorScope 来获得您想要的行为。如果子协程在主管范围内失败,它不会立即取消其他子协程。只有在未处理异常的情况下,子项才会被取消。

有关详细信息,请参阅此处: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")
}

在研究了 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 returns 所有参数的公共超类型列表。如果我们有一些

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)

但这很脆弱,因为您必须注意确保对每个可挂起的调用都执行此操作。它也违反了 "sequential by default"

的 Kotlin 学说

总而言之:当前的设计虽然一开始违反直觉,但作为一个实用的解决方案确实有意义。它还加强了不使用 async-await 的规则,除非你正在对任务进行并行分解。

虽然所有的答案都在那里,但让我在其中提供更多信息,以帮助其他用户。据记载 here (Official doc) 是:-

If a coroutine encounters exception other than CancellationException, it cancels its parent with that exception. This behaviour cannot be overridden and is used to provide stable coroutines hierarchies for structured concurrency which do not depend on CoroutineExceptionHandler implementation. The original exception is handled by the parent (In GlobalScope) when all its children terminate.

It does not make sense to install an exception handler to a coroutine that is launched in the scope of the main runBlocking, since the main coroutine is going to be always cancelled when its child completes with exception despite the installed handler.

希望这会有所帮助。