Kotlin协程异常一文通

异常处理可能是学习 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 ,所以它失败了。

我们一开始便知道,常规函数中未捕获异常会被重新抛出。但协程中的未捕获异常并非如此。不然,我们就可以从外部处理它,上面例子中的应用程序也就不会崩溃了。

那么,协程中的未捕获异常会发生什么呢?你可能知道,协程最具创新性的特性之一就是结构化并发。为了实现结构化并发,CoroutineScopeJob 对象以及协程和子协程的 Job 对象形成了一个父子关系的层次结构。未捕获的异常不是被重新抛出,而是沿着 Job 层次结构向上传播。这种异常传播会导致导致父 Job 失败,进而取消所有的 Job (这里的 Job 并没有翻译成作业或者任务,因为在协程中,Job 就是 launch 返回的对象)。

上面代码示例的 Job 层次结构如下所示:

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

传播的异常可以通过设置一个 CoroutineExceptionHandler 来处理。如果没有设置,线程级别的未捕获异常处理程序将被调用,这取决于平台,可能会导致异常的打印输出,然后应用程序终止。

在我看来,我们有两种不同的异常处理机制------ try-catchCoroutineExceptionHandler 。这也恰恰是协程中异常处理如此复杂的主要原因之一。

关键点 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

launchasync 协程中的未捕获异常都会立即向上传播到 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 层次结构中设置了一个新的、独立的嵌套作用域,其 JobSupervisorJob

因此,代码如下:

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 那样重新抛出失败协程的异常,也不会将其异常传播给其父级------topLevelScopeJob

另一个需要理解的关键点是,异常只会向上传播,直到它们到达顶级作用域或 SupervisorJob。这意味着 Coroutine 2Coroutine 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 层次结构中安装一个新的独立子作用域,其 JobSupervisorJob。这个新作用域不会将其异常向上传播到 Job 层次结构,因此它必须自己处理异常。直接从 supervisorScope 启动的协程是顶级协程。顶级协程在用 launch()async() 启动时,其行为与子协程不同,此外,还可以在它们中安装 CoroutineExceptionHandlers

SupervisorJob vs supervisorScope

我们上一节的关键点提到,supervisorScope{}JobSupervisorJob。那么,如果我们单独使用 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

总结

以上便是本文的全部内容!

这些是我花了很多时间试图理解协程异常处理时自己识别出的关键点,希望能帮到各位。

相关推荐
用户3031057186858 分钟前
Kotlin协程的介绍
kotlin
云深不知云2 小时前
Kotlin协程(一)协程简析
kotlin
缘来的精彩2 小时前
adb常用的20个命令
android·adb·kotlin
tangweiguo030519872 小时前
Android kotlin通知功能完整实现指南:从基础到高级功能
android·kotlin
louisgeek4 小时前
Kotlin 协程 coroutineScope 和 supervisorScope 的区别
kotlin
alexhilton20 小时前
理解Jetpack Compose中副作用函数的内部原理
android·kotlin·android jetpack
人生游戏牛马NPC1号1 天前
学习Android(四)
android·kotlin
百锦再1 天前
Kotlin学习基础知识大全(上)
android·xml·学习·微信·kotlin·studio·mobile