译者的话:
这是一篇翻译Kotlin协程中异常处理的文章,很清晰的解答了我的一些疑惑,原文来自Why exception handling with Kotlin Coroutines is so hard and how to successfully master it!,作者另有一篇描述Kotlin协程使用常见错误的文章,已经有人翻译,[译] 关于 Kotlin Coroutines, 你可能会犯的 7 个错误。
本文通过代码示例,得到了6个协程异常处理相关的要点。需要指出的是,其中的异常处理中多次出现了单词"re-thrown",表示的是函数中出现了异常,通过调用栈向上抛出,即我们在不使用协程时Kotlin异常的传播方式。区别于Kotlin协程的结构化并发中沿着Job层次结构向上传播异常,下文中不翻译该词。
下面开始译文。
在学习协程时,异常处理可能是其中最难的部分之一。在这篇博客中,我将描述它复杂的原因,并提供一些要点来更好地理解该主题。通过本文可以学到如何在自己的APP中正确处理异常。
不使用协程时Kotlin的异常处理
纯Kotlin代码中的异常处理(没有协程)非常简单,我们基本上只使用try-catch
语句来处理异常:
try {
// some code
throw RuntimeException("RuntimeException in 'some code'")
} catch (exception: Exception) {
println("Handle $exception")
}
// 输出:
// Handle java.lang.RuntimeException: RuntimeException in 'some code'
如果在异常在普通函数中抛出,则该异常将被该函数继续re-thrown。这意味着我们能够使用try-catch
语句来处理调用位置上的异常:
fun main() {
try {
functionThatThrows()
} catch (exception: Exception) {
println("Handle $exception")
}
}
fun functionThatThrows() {
// some code
throw RuntimeException("RuntimeException in regular function")
}
// 输出:
// Handle java.lang.RuntimeException: RuntimeException in regular function
协程中的try-catch
现在我们来看协程中的try-catch
的用法。在协程内使用try-catch
(下面的示例中launch
的开始之处)可以如我们预期的那样,异常被捕获了:
fun main() {
val topLevelScope = CoroutineScope(Job())
topLevelScope.launch {
try {
throw RuntimeException("RuntimeException in coroutine")
} catch (exception: Exception) {
println("Handle $exception")
}
}
Thread.sleep(100)
}
// 输出:
// Handle java.lang.RuntimeException: RuntimeException in coroutine
可我们在try
块中launch
另一个协程时
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)
}
// 输出:
// Exception in thread "main" java.lang.RuntimeException: RuntimeException in nested coroutine
可以在输出中看到不再处理异常,应用程序崩溃了,这就让人意外。因为根据我们的经验,在try-catch
语句,我中,们期望try
块中的每个异常都将被捕获,并进入catch
块。但上方的情况怎么就不合预期了呢?
这是因为,协程本身(区别于协程内)并不能通过try-catch
语句捕获异常。在上面的示例中,协程始于内层的launch
,而它没有捕捉RuntimeException
,因此崩溃了,就这么简单。
正如我们在开始所看到的那样,普通函数中未捕获的异常继续re-thrown。而这并不能类比到协程未捕获异常的情况。否则,我们将能够从外部处理异常,上面示例中的应用程序也不会崩溃。
那么,协程中未捕获的异常会发生什么呢?协程最具创新性的功能之一结构化并发就是处理这一问题的。结构化并发的特性,是通过CoroutineScope
的Job
对象,协程中的Job
对象,以及协程父子关系的层次结构来实现。未捕获的异常将不会向上re-thrown,而是在Job层次结构中传播。这种异常传播会导致父级Job
失败,因此其所有子Job
都会取消。
上面的代码的Job层次结构如下所示:
首先子协程的异常传播到顶层协程的Job
,然后传播到topLevelScope
的Job
。
可以通过配置CoroutineExceptionHandler
来处理传播的异常。如果未配置CoroutineExceptionHandler
,则将调用线程的未捕获异常处理程序(UncaughtExceptionHandler
),而这将取决于不同平台,可能会导致打印异常,然后终止APP。
我认为,事实是,我们有两种不同的异常处理机制try-catch
和CoroutineExceptionHandler
,这也是协程异常处理复杂的主要原因之一。
要点 1
如果协程内部不使用try-catch
语句处理异常,则该异常也不会re-thrown,因此不能由外部try-catch
语句处理。相反,该异常是“在Job层次结构中传播”,可以由已配置的CoroutineExceptionHandler处理。如果未配置CoroutineExceptionHandler,则异常会到达UncaughtExceptionHandler
。
CoroutineExceptionHandler
现在我们知道,try-catch
语句对于在try
块中启动的一个会抛出异常的协程来说,是无效的。因此,我们要用CoroutineExceptionHandler
取而代之!我们可以向launch
协程构建器传递context
。由于CoroutineExceptionHandler
是 ContextElement
,我们可以通过launch
在启动子协程时配置CoroutineExceptionHandler
:
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)
}
// 输出:
// Exception in thread "DefaultDispatcher-worker-2" java.lang.RuntimeException: RuntimeException in nested coroutine
但是,上面例子的异常并未被coroutineExceptionHandler
处理,因此APP崩溃了!这是因为在子协程上配置CoroutineExceptionHandler
不会产生任何效果。我们必须将CoroutineExceptionHandler
配置在协程作用域或顶级协程中,如下所示:
// ...
val topLevelScope = CoroutineScope(Job() + coroutineExceptionHandler)
// ...
或像这样:
// ...
topLevelScope.launch(coroutineExceptionHandler) {
// ...
只有这样,CoroutineExceptionHandler
才能处理异常:
// ..
// 输出:
// Handle java.lang.RuntimeException: RuntimeException in nested coroutine in CoroutineExceptionHandler
要点 2
为了使CoroutineExceptionHandler生效,必须将其配置在CoroutineScope中或顶层协程中。
try-catch
VS CoroutineExceptionHandler
如上文所示,我们有两种方式处理异常:将协程中的代码用try-catch
包起来或配置CoroutineExceptionHandler
。那我们要如何抉择?
CoroutineExceptionHandler的官方文档提供了很好的答案:
“
CoroutineExceptionHandler
是一种不得已的全局捕获机制。在CoroutineExceptionHandler
中,无法从异常中恢复。当调用CoroutineExceptionHandler
时,协程也伴随相应的异常结束。通常,CoroutineExceptionHandler
用于输出异常日志,显示某错误消息,终止或重新启动应用程序。
如果您需要在代码的特定部分处理异常,建议在协程内部使用
try/catch
包裹相应代码。这样,您可以避免协程的异常终止,重试操作或采取其他措施”
我要在这里提及的另一个方面是,通过直接在协程中处理异常,try-catch
我们没有利用结构化并发中取消的相关特性。例如,让我们假设我们并行启动两个协程。他们俩都相互依赖,如果一个失败了,那么另一个的完成也就没有意义。如果我们现在在每个协程中使用try-catch
来处理异常,则异常不会传播到父级,因此其他协程也不会被取消。而这就造成了资源的浪费。在这种情况下,我们应该使用CoroutineExceptionHandler
。
要点 3
如果想要在协程完成之前进行重试或做其他操作,需要使用try/catch。要记住,直接在协程中捕获异常,该异常不会在Job层次结构中传播,并且也不会用到结构化并发的取消功能。使用CoroutineExceptionHandler的逻辑发生在协程完成之后。
launch{}
与 async{}
到目前为止,我们仅使用launch
启动新的协程。但是,launch
启动的协程和与async
启动的协程的异常处理是完全不同的。让我们看下面的例子:
fun main() {
val topLevelScope = CoroutineScope(SupervisorJob())
topLevelScope.async {
throw RuntimeException("RuntimeException in async coroutine")
}
Thread.sleep(100)
}
// 无输出
本示例没有输出。那么RuntimeException
这里发生了什么?仅仅是被忽略了吗?不。在async
启动的协程中,未捕获的异常也会立即在Job层次结构中传播。但是与launch
启动的协程相反,异常不是由已配置的CoroutineExceptionHandler
处理,也不会传递给UncaughtExceptionHandler
。
launch
启动协程的返回类型为Job
,没有返回值。如果需要协程的某些结果,则需要使用async
,它返回一个Deferred
,这是一种特殊的类型Job
,会额外保存一个计算结果。如果async
协程失败,则将异常封装在Deferred
返回类型中,并在我们调用suspend函数.await()
时将其重新抛出。
因此,我们可以用try-catch
语句包裹.await()
。既然.await()
是一个suspend
函数,我们要启动一个新的协程来调用它:
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)
}
// 输出:
// Handle java.lang.RuntimeException: RuntimeException in async coroutine in try/catch
注意:如果async
协程是顶级协程,则仅将异常封装在Deferred
中。否则,即使不对其进行调用.await()
,异常也将立刻传播到Job层次结构中,并由CoroutineExceptionHandler
处理或传递给UncaughtExceptionHandler
,如以下示例所示:
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)
}
// 输出:
// Handle java.lang.RuntimeException: RuntimeException in async coroutine in CoroutineExceptionHandler
要点 4
launch
和async
协程中的未捕获的异常会立即在Job层次结构中传播。但是,如果是launch
启动的是顶层协程,则该异常将由CoroutineExceptionHandler
处理或传递给UncaughtExceptionHandler
。另一方面,如果是async
启动的顶层协程,则异常被封装在Deferred
返回类型中,并在其调用.await()
时re-thrown。
coroutineScope{}
的异常处理
之前我们讨论try-catch
与协程时,失败的协程将其异常传播到Job层次结构中,而不是re-thrown,因此协程外部的try-catch
无效。
但是,当我们使用coroutineScope{}
作用域函数将失败的协程包裹起来时,有趣的事情发生了:
fun main() {
val topLevelScope = CoroutineScope(Job())
topLevelScope.launch {
try {
coroutineScope {
launch {
throw RuntimeException("RuntimeException in nested coroutine")
}
}
} catch (exception: Exception) {
println("Handle $exception in try/catch")
}
}
Thread.sleep(100)
}
// 输出:
// Handle java.lang.RuntimeException: RuntimeException in nested coroutine in try/catch
现在,我们可以使用try-catch
语句处理异常了。因此,作用域函数coroutineScope{}
向上抛出异常,而不是将其传播到Job层次结构中。
coroutineScope{}
主要用于suspend
函数内来实现“并行分解”的功能。这些suspend
函数将re-thrown协程的异常,因此我们可以相应编写异常处理逻辑。
要点 5
作用域函数coroutineScope{}
re-thrown其失败的子协程的异常,而不是将它们传播到Job层次结构中,这使我们能够用try-catch
来处理协程的异常
supervisorScope{}
的异常处理
通过使用作用域函数supervisorScope{}
,我们在Job层次结构中设置了一个新的独立嵌套作用域,该协程作用域用SupervisorJob
做为Job
。
fun main() {
val topLevelScope = CoroutineScope(Job())
topLevelScope.launch {
val job1 = launch {
println("starting Coroutine 1")
}
supervisorScope {
val job2 = launch {
println("starting Coroutine 2")
}
val job3 = launch {
println("starting Coroutine 3")
}
}
}
Thread.sleep(100)
}
上述代码会创建的Job层次结构如下:
此处理解异常处理的关键是,supervisorScope
是一个必须独立处理异常的新的独立子范围。它不会像coroutineScope
那样向上抛出异常,也不会传播到父作用域中去,即topLevelScope
中的Job
。
要理解的另一件至关重要的事情是,异常只会向上传播,直到它们到达顶级作用域或SupervisorJob
。这意味着协程2和协程3现在是顶级协程。
这也意味着我们现在可以为这些顶级协程配置CoroutineExceptionHandler
:
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 {
println("starting Coroutine 3")
}
}
}
Thread.sleep(100)
}
// 输出:
// starting Coroutine 1
// starting Coroutine 2
// Handle java.lang.RuntimeException: Exception in Coroutine 2 in CoroutineExceptionHandler
// starting Coroutine 3
直接在supervisorScope
中启动的协程同样是顶级协程,也意味着其中async
启动的协程,会将异常封装在Deferred
对象中,只有在调用.await()
时被向上抛出 。
// ... 省略代码与上例相同
supervisorScope {
val job2 = async {
println("starting Coroutine 2")
throw RuntimeException("Exception in Coroutine 2")
}
// ...
// 输出:
// starting Coroutine 1
// starting Coroutine 2
// starting Coroutine 3
要点 6
作用域函数supervisorScope{}
在Job层次结构中会配置一个新的独立子作用域,并使用SupervisorJob
作为作用域的Job。这个新作用域不会在Job层次结构中传播异常,因此它要自行处理其异常。直接从supervisorScope
中启动的协程是顶级协程。顶级协程在以launch()
或async()
启动时的表现与子协程不同。此外,还可以通过在顶级协程中配置CoroutineExceptionHandlers
来处理异常。