[译] 为什么Kotlin协程的异常处理这么难?到底要如何掌握它?

1,656 阅读6分钟

译者的话:

这是一篇翻译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。而这并不能类比到协程未捕获异常的情况。否则,我们将能够从外部处理异常,上面示例中的应用程序也不会崩溃。

那么,协程中未捕获的异常会发生什么呢?协程最具创新性的功能之一结构化并发就是处理这一问题的。结构化并发的特性,是通过CoroutineScopeJob对象,协程中的Job对象,以及协程父子关系的层次结构来实现。未捕获的异常将不会向上re-thrown,而是在Job层次结构中传播。这种异常传播会导致父级Job失败,因此其所有子Job都会取消。

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

首先子协程的异常传播到顶层协程的Job,然后传播到topLevelScopeJob

可以通过配置CoroutineExceptionHandler来处理传播的异常。如果未配置CoroutineExceptionHandler,则将调用线程的未捕获异常处理程序(UncaughtExceptionHandler),而这将取决于不同平台,可能会导致打印异常,然后终止APP。

我认为,事实是,我们有两种不同的异常处理机制try-catchCoroutineExceptionHandler,这也是协程异常处理复杂的主要原因之一。

要点 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

launchasync协程中的未捕获的异常会立即在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来处理异常。