异常处理可能是学习 Kotlin 协程过程中最困难的部分之一。本文中,我将阐述其复杂性的原因,并提供一些关键点,以帮助您深入理解这个主题。掌握这些内容后,我们将能够在自己的应用中成功处理协程异常。

纯Kotlin的异常处理
在纯 Kotlin 代码中(不使用协程)处理异常是非常直接的。我们基本上只需要使用 try-catch
语句来处理异常。
Kotlin
try {
// some code
throw RuntimeException("RuntimeException in 'some code'")
} catch (exception: Exception) {
println("Handle $exception")
}
// Output
// Handle java.lang.RuntimeException: RuntimeException in 'some code'
当然,如果你想在一个函数调用中返回异常,而不是抛出异常(throw
),Kotlin 提供了一种更好的解决方案:
Kotlin
runCatching {
throw RuntimeException("RuntimeException in 'some code'")
}.onSuccess {
println("succeed")
}.onFailure {
println("failed: $it")
}
// Output
// failed: java.lang.RuntimeException: RuntimeException in 'some code'
现在,我们只考虑第一种情况。
如果在常规函数中抛出了异常,该异常会被函数重新抛出。这意味着我们可以在调用点使用 try-catch
语句来处理这个异常。
Kotlin
fun main() {
try {
functionThatThrows()
} catch (exception: Exception) {
println("Handle $exception")
}
}
fun functionThatThrows() {
// some code
throw RuntimeException("RuntimeException in regular function")
}
// Output
// Handle java.lang.RuntimeException: RuntimeException in regular function
协程中的 try-catch
现在,让我们看看在 Kotlin 协程中使用 try-catch
的情况。
在协程内部使用它(如下面的例子中,使用 launch
启动协程)时,异常会被捕获,工作原理符合预期。
Kotlin
fun main() {
val topLevelScope = CoroutineScope(Job())
topLevelScope.launch {
try {
throw RuntimeException("RuntimeException in coroutine")
} catch (exception: Exception) {
println("Handle $exception")
}
}
Thread.sleep(100)
}
// Output
// Handle java.lang.RuntimeException: RuntimeException in coroutine
但是,当我们在 try-catch
块内部 launch
另一个协程时:
Kotlin
fun main() {
val topLevelScope = CoroutineScope(Job())
topLevelScope.launch {
try {
launch {
throw RuntimeException("RuntimeException in nested coroutine")
}
} catch (exception: Exception) {
println("Handle $exception")
}
}
Thread.sleep(100)
}
// Output
// Exception in thread "main" java.lang.RuntimeException: RuntimeException in nested coroutine
注意这里直接抛出了异常,而不是在 catch
块中完成了处理。
从输出中可以看到,异常没有被处理,应用程序崩溃了。
出乎意料!令人困惑!
根据我们对 try-catch
的了解和经验,我们期望 try
块中的每个异常都能被捕获并进入 catch
块。但为什么在这里不是这样呢?
一个协程如果没有用自己的try-catch
子句捕获异常,它便会以异常的形式结束,或者说,它失败了。在上面的例子中,try
块内部启动的协程没有自己捕获 RuntimeException
,所以它失败了。
我们一开始便知道,常规函数中未捕获异常会被重新抛出。但协程中的未捕获异常并非如此。不然,我们就可以从外部处理它,上面例子中的应用程序也就不会崩溃了。
那么,协程中的未捕获异常会发生什么呢?你可能知道,协程最具创新性的特性之一就是结构化并发。为了实现结构化并发,CoroutineScope
的 Job
对象以及协程和子协程的 Job
对象形成了一个父子关系的层次结构。未捕获的异常不是被重新抛出,而是沿着 Job
层次结构向上传播。这种异常传播会导致导致父 Job
失败,进而取消所有的 Job
(这里的 Job
并没有翻译成作业或者任务,因为在协程中,Job
就是 launch
返回的对象)。
上面代码示例的 Job
层次结构如下所示:

子协程的异常被传播到顶级协程的 Job
(1),然后继续传播到 topLevelScope 的 Job
(2)。

传播的异常可以通过设置一个 CoroutineExceptionHandler
来处理。如果没有设置,线程级别的未捕获异常处理程序将被调用,这取决于平台,可能会导致异常的打印输出,然后应用程序终止。
在我看来,我们有两种不同的异常处理机制------ try-catch
和 CoroutineExceptionHandler
。这也恰恰是协程中异常处理如此复杂的主要原因之一。
关键点 1
如果一个协程没有用自己的
try-catch
子句处理异常,那么这个异常不会被抛出,因此不能被外层的try-catch
子句处理。相反,异常会沿着Job
层次结构向上传播。如果你设置了CoroutineExceptionHandler
, 那么会被CoroutineExceptionHandler
处理;如果没有设置,那么线程的未捕获异常处理程序将被调用。
CoroutineExceptionHandler
好的,现在我们知道,如果在 try
块中启动一个失败的协程,try-catch
是无用的。所以,我们来设置一个 CoroutineExceptionHandler
吧!我们可以向 launch
协程构建器传递一个上下文。由于 CoroutineExceptionHandler
是一个 ContextElement
,我们可以在启动子协程时,通过传递给 launch
:
Kotlin
fun main() {
val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, exception ->
println("Handle $exception in CoroutineExceptionHandler")
}
val topLevelScope = CoroutineScope(Job())
topLevelScope.launch {
launch(coroutineExceptionHandler) {
throw RuntimeException("RuntimeException in nested coroutine")
}
}
Thread.sleep(100)
}
// Output
// Exception in thread "DefaultDispatcher-worker-2" java.lang.RuntimeException: RuntimeException in nested coroutine
注意,这个异常依然没有被 Handle 。
我们的异常并没有被我们的 coroutineExceptionHandler
处理,因此应用程序崩溃了!这是因为设置在子协程上的 CoroutineExceptionHandler
没有任何效果。我们必须在作用域或顶级协程中设置处理 CoroutineExceptionHandler
,所以需要像这样:
Kotlin
// ...
val topLevelScope = CoroutineScope(Job() + coroutineExceptionHandler)
// ...
或者
Kotlin
// ...
topLevelScope.launch(coroutineExceptionHandler) {
// ...
只有这样,异常处理程序才能处理异常:
Kotlin
val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, exception ->
println("Handle $exception in CoroutineExceptionHandler")
}
val topLevelScope = CoroutineScope(Job() + coroutineExceptionHandler)
topLevelScope.launch {
launch {
throw RuntimeException("RuntimeException in nested coroutine")
}
}
Thread.sleep(100)
// Output
// Handle java.lang.RuntimeException: RuntimeException in nested coroutine in CoroutineExceptionHandler
关键点 2
为了让
CoroutineExceptionHandler
生效,它必须被设置在CoroutineScope
中或者在一个顶级协程中。
try-catch
VS CoroutineExceptionHandler
正如我们之前所讲,我们有两种处理异常的选项:使用 try-catch
或者设置一个 CoroutineExceptionHandler
。我们应该何时选择哪种选项呢?
CoroutineExceptionHandler
的官方文档提供了一些很好的答案:
CoroutineExceptionHandler is a last-resort mechanism for global "catch all" behavior. You cannot recover from the exception in the CoroutineExceptionHandler. The coroutine had already completed with the corresponding exception when the handler is called. Normally, the handler is used to log the exception, show some kind of error message, terminate, and/ or restart the application.
CoroutineExceptionHandler
是一种全局"捕获所有"行为的最后手段机制。您不能在 CoroutineExceptionHandler
中从异常中恢复。当调用处理程序时,协程已经完成了相应的异常。通常,处理程序用于记录异常、显示某种错误消息、终止和/或重启应用程序。
If you need to handle exception in a specific part of the code, it is recommended to use try/catch around the corresponding code inside your coroutine. This way you can prevent completion of the coroutine with the exception (exception is now caught), retry the operation, and/ or take other arbitrary actions:
如果您需要在代码的特定部分处理异常,建议在协程内的相应代码周围使用 try/catch
。这样您可以防止协程失败(因为现在异常现在被捕获了),可以进行重试操作,或采取其他任意动作:
kotlin
scope.launch {
// launch child coroutine in a scope
try {
// do something
} catch (e: Throwable) {
// handle exception
}
}
我想在这里提到的另一个方面是,协程的异常之所以会向上传播,主要是因为协程的结构化并发的设计理念。如果我们在协程中直接使用 try-catch
处理异常,我们就没有利用结构化并发(Structured Concurrency )与取消相关的特性。想象一下,我们并行启动了两个协程,它们彼此依赖,所以如果其中一个失败了,另一个的完成就没有意义了。现在,我们在每个协程中使用 try-catch
来处理异常,异常就不会传播到父级,因此另一个协程不会被取消,这将浪费很多资源。在这种情况下,我们应该使用 CoroutineExceptionHandler
记录下这个异常。
关键点 3
如果想在协程完成之前重试操作或执行其他动作,请使用
try/catch
。记住,通过在协程中直接捕获异常,异常不会在Job
层次结构中向上传播,因此就没有利用结构化并发的取消功能。对于应该在协程已经完成后发生的逻辑,请使用CoroutineExceptionHandler
。
launch{}
vs async{}
到目前为止,在我们的示例中,我们只使用了 launch
来启动新的协程。然而,使用 launch
启动的协程和使用 async
启动的协程在异常处理方面有所不同。让我们看看下面的例子:
Kotlin
fun main() {
val topLevelScope = CoroutineScope(SupervisorJob())
topLevelScope.async {
throw RuntimeException("RuntimeException in async coroutine")
}
Thread.sleep(100)
}
// No output
Kotlin 老手可能知道为什么。
这个例子没有产生任何输出。那么这里的 RuntimeException
发生了什么?它只是被忽略了吗?不是的。在使用 async
启动的协程中,未捕获的异常也会立即向上传播到 Job
层次结构。但是,与使用 launch
启动的协程不同,这些异常不会被安装的 CoroutineExceptionHandler
处理,也不会传递给线程的未捕获异常处理程序。
使用 launch
启动的协程的返回类型是 Job
,它只是协程的表示,没有返回值。如果我们需要从协程中获取一些结果,我们必须使用 async
,它返回一个 Deferred
,这是一种特殊的 Job
,它额外保存了一个结果值。如果 async
协程失败,异常会被封装在 Deferred
返回类型中,并在我们调用挂起函数 .await()
获取结果时重新抛出。
因此,我们可以用 try-catch
子句包围对 .await()
的调用。由于 .await()
是一个挂起函数,我们必须启动一个新的协程才能调用它:
Kotlin
fun main() {
val topLevelScope = CoroutineScope(SupervisorJob())
val deferredResult = topLevelScope.async {
throw RuntimeException("RuntimeException in async coroutine")
}
topLevelScope.launch {
try {
deferredResult.await()
} catch (exception: Exception) {
println("Handle $exception in try/catch")
}
}
Thread.sleep(100)
}
// Output:
// Handle java.lang.RuntimeException: RuntimeException in async coroutine in try/catch
注意:只有当 async
协程是顶级协程时,异常才会被封装在 Deferred
中。否则,异常会立即向上传播到 Job
层次结构,并被 CoroutineExceptionHandler
处理,或者即使没有在它上面调用 .await()
,也会传递给线程的未捕获异常处理程序,如下面的示例所示:
Kotlin
fun main() {
val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, exception ->
println("Handle $exception in CoroutineExceptionHandler")
}
val topLevelScope = CoroutineScope(SupervisorJob() + coroutineExceptionHandler)
topLevelScope.launch {
async {
throw RuntimeException("RuntimeException in async coroutine")
}
}
Thread.sleep(100)
}
// Output
// Handle java.lang.RuntimeException: RuntimeException in async coroutine in CoroutineExceptionHandler
即使我们没有调用 .await()
,该异常也会向上传播被 CoroutineExceptionHandler
处理。
关键点 4
在
launch
和async
协程中的未捕获异常都会立即向上传播到Job
层次结构。然而,如果顶级协程是用launch
启动的,异常会被CoroutineExceptionHandler
处理,或者传递给线程的未捕获异常处理程序。另一方面,如果顶级协程是用async
启动的,异常会被封装在Deferred
返回类型中,并在对其调用.await()
时重新抛出。
coroutineScope{}
的异常处理特性
当我们在本文开头讨论协程的 try-catch
时,我告诉过您,失败的协程会将其异常向上传播到 Job
层次结构,而不是重新抛出它,因此,外部的 try-catch
是无效的。
然而,当我们将失败的协程与 coroutineScope{}
作用域函数包围时,会发生一些有趣的事情:
Kotlin
fun main() {
val topLevelScope = CoroutineScope(Job())
topLevelScope.launch {
try {
coroutineScope { // 多了一层 coroutineScope
launch {
throw RuntimeException("RuntimeException in nested coroutine")
}
}
} catch (exception: Exception) {
println("Handle $exception in try/catch")
}
}
Thread.sleep(100)
}
// Output
// Handle java.lang.RuntimeException: RuntimeException in nested coroutine in try/catch
我们现在能够使用 try-catch
子句来处理异常。因此,coroutineScope{}
作用域函数会重新抛出其失败子协程的异常,而不是将它们向上传播到 Job
层次结构。
coroutineScope{}
主要用于挂起函数,以实现并行分解。这些挂起函数将重新抛出其失败协程的异常,因此我们可以相应地设置我们的异常处理逻辑。
关键点 5
coroutineScope{}
作用域函数会重新抛出其失败子协程的异常,而不是将它们向上传播到Job
层次结构,这允许我们使用try-catch
处理失败协程的异常。
supervisorScope{}
的异常处理特性
通过使用 supervisorScope{}
作用域函数,我们在 Job
层次结构中设置了一个新的、独立的嵌套作用域,其 Job
为 SupervisorJob
。
因此,代码如下:
Kotlin
fun main() {
val topLevelScope = CoroutineScope(Job())
topLevelScope.launch {
val job1 = launch { // Coroutine 1
println("starting Coroutine 1")
}
supervisorScope {
val job2 = launch { // Coroutine 2
println("starting Coroutine 2")
}
val job3 = launch { // Coroutine 3
println("starting Coroutine 3")
}
}
}
Thread.sleep(100)
}
上述代码会创建以下 Job
层次结构:

现在,关于异常处理,这里至关重要的是要理解 supervisorScope
是一个新的独立子作用域,它必须自己处理异常。它不会像 coroutineScope
那样重新抛出失败协程的异常,也不会将其异常传播给其父级------topLevelScope 的 Job
。
另一个需要理解的关键点是,异常只会向上传播,直到它们到达顶级作用域或 SupervisorJob
。这意味着 Coroutine 2
和 Coroutine 3
现在是顶级协程。
这也意味着我们现在可以在它们中安装一个实际上会被调用的 CoroutineExceptionHandler
:
Kotlin
fun main() {
val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, exception ->
println("Handle $exception in CoroutineExceptionHandler")
}
val topLevelScope = CoroutineScope(Job())
topLevelScope.launch {
val job1 = launch {
println("starting Coroutine 1")
}
supervisorScope {
val job2 = launch(coroutineExceptionHandler) {
println("starting Coroutine 2")
throw RuntimeException("Exception in Coroutine 2")
}
val job3 = launch {
delay(200)
println("starting Coroutine 3")
}
}
}
Thread.sleep(400)
}
// Output
// starting Coroutine 1
// starting Coroutine 2
// Handle java.lang.RuntimeException: Exception in Coroutine 2 in CoroutineExceptionHandler
// starting Coroutine 3
我们对比一下 coroutineScope
的输出结果:
Kotlin
fun main() {
val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, exception ->
println("Handle $exception in CoroutineExceptionHandler")
}
val topLevelScope = CoroutineScope(Job())
topLevelScope.launch {
val job1 = launch {
println("starting Coroutine 1")
}
coroutineScope { // 这里把 supervisorScope 改成 coroutineScope
val job2 = launch(coroutineExceptionHandler) {
println("starting Coroutine 2")
throw RuntimeException("Exception in Coroutine 2")
}
val job3 = launch {
delay(200)
println("starting Coroutine 3")
}
}
}
Thread.sleep(400)
}
// Output
// starting Coroutine 1
// starting Coroutine 2
// Exception in thread "DefaultDispatcher-worker-2" java.lang.RuntimeException: Exception in Coroutine 2
在 supervisorScope
中直接启动的协程是顶级协程,这也意味着 async
协程现在将其异常封装在它们的 Deferred
对象中:
Kotlin
// ...
supervisorScope {
val job2 = async {
println("starting Coroutine 2")
throw RuntimeException("Exception in Coroutine 2")
}
// ...
// Output:
// starting Coroutine 1
// starting Coroutine 2
// starting Coroutine 3
只有在调用 .await()
时才会被重新抛出。
关键点 6
作用域函数
supervisorScope{}
在Job
层次结构中安装一个新的独立子作用域,其Job
为SupervisorJob
。这个新作用域不会将其异常向上传播到Job
层次结构,因此它必须自己处理异常。直接从supervisorScope
启动的协程是顶级协程。顶级协程在用launch()
或async()
启动时,其行为与子协程不同,此外,还可以在它们中安装CoroutineExceptionHandlers
。
SupervisorJob
vs supervisorScope
我们上一节的关键点提到,supervisorScope{}
的 Job
是 SupervisorJob
。那么,如果我们单独使用 SupervisorJob
和使用 supervisorScope{}
效果是不是一样的?一样!但并不是完全一样。
我们先来看看单独使用 SupervisorJob
的情况:
Kotlin
val scope = CoroutineScope(SupervisorJob())
with(scope) { // !!! 注意这个地方使用的 with
launch {
delay(200)
println("starting Coroutine 1")
}
launch {
throw Exception("From Coroutine 2")
}
launch {
delay(200)
println("starting Coroutine 3")
}
}
Thread.sleep(400)
// Output
// Exception in thread "DefaultDispatcher-worker-2" java.lang.Exception: From Coroutine 2
// starting Coroutine 1
// starting Coroutine 3
我们发现,使用 SupervisorJob
的结果和使用 supervisorScope{}
一样,虽然 Coroutine 2 失败了,但是并不影响其他两个协程的执行。这里一定要注意,我们并没有使用像之前的例子那样,使用 scope.launch
去启动一个协程之后,再启动三个子协程。因为如果这样做,那么这三个子协程将不再是 scope
的直接子协程,他们依然会向上传播异常。
上面的代码替换成 supervisorScope
,结果是一样的:
Kotlin
val scope = CoroutineScope(Job())
scope.launch {// 因为 supervisorScope 只能在携程中使用,所以必须 launch
supervisorScope {
launch {
delay(200)
println("starting Coroutine 1")
}
launch {
throw Exception("From Coroutine 2")
}
launch {
delay(200)
println("starting Coroutine 3")
}
}
}
Thread.sleep(400)
// Output
// Exception in thread "DefaultDispatcher-worker-3" java.lang.Exception: From Coroutine 2
// starting Coroutine 3
// starting Coroutine 1
可能因为执行顺序上的差异,顺序上会有区别,但是逻辑上是是一致的。
所以,这个时候,其实这两是一样。他们都只对直接 Job
起作用。那么,这两什么时候不一样呢?我们先来看下面的例子:
Kotlin
val scope = CoroutineScope(SupervisorJob())
with(scope) {
launch {
delay(200)
println("starting Coroutine 1")
}
launch {
launch {
delay(200)
println("starting Coroutine 2 - 1")
}
launch {
throw Exception("From Coroutine 2 - 2")
}
}
launch {
delay(200)
println("starting Coroutine 3")
}
}
Thread.sleep(400)
// Output
// Exception in thread "DefaultDispatcher-worker-4" java.lang.Exception: From Coroutine 2 - 2
// starting Coroutine 1
// starting Coroutine 3
第二个协程,我们额外启动了两个协程,并在 2 - 2 协程抛出了异常,此时我们发现,整个 2 协程都取消了。如果我们不想让整个 2 协程取消,可以这么做:
Kotlin
//...
launch(SupervisorJob()) {
throw Exception("From Coroutine 2 - 2")
}
//...
// Output
// Exception in thread "DefaultDispatcher-worker-5" java.lang.Exception: From Coroutine 2 - 2
// starting Coroutine 2 - 1
// starting Coroutine 1
// starting Coroutine 3
我们可以在 launch
处,设置一个 SupervisorJob()
,让其启动的协程,自己消化异常。如果你不想抛出异常,可以在此设置一个 CoroutineExceptionHandler
。
Kotlin
//...
launch(SupervisorJob() + CoroutineExceptionHandler { _, ex ->
println("Handle $ex")
}) {
throw Exception("From Coroutine 2 - 2")
}
//...
// Output
// Handle java.lang.Exception: From Coroutine 2 - 2
// starting Coroutine 2 - 1
// starting Coroutine 1
// starting Coroutine 3
关键点 7
supervisorScope{}
和SupervisorJob
效果上是一样的,但是注意都必须作用于直接子协程。如果一个协程可以自己处理异常,那么他就可以设置CoroutineExceptionHandler
。
总结
以上便是本文的全部内容!
这些是我花了很多时间试图理解协程异常处理时自己识别出的关键点,希望能帮到各位。