协程异常机制与优雅封装 | 技术点评

4,362 阅读7分钟

前言

本文主要包括以下内容
1.协程的3种作用域以及异常的传播方式
2.协程异常的两种捕获方式及对比
3.协程异常的优雅封装

本文主要内容翻译自 why-exception-handling-with-kotlin-coroutines-is-so-hard-and-how-to-successfully-master-it/,感兴趣的同学可以直接查看原文

协程的异常是怎么传播的?

首先了解下协程作用域

协程作用域分为顶级作用域,协同作用域与主从作用域,分别对应GlobalScope,coroutineScope,supervisorScope
作用分析:

说明:

  • C2-1发生异常的时候,C2-1->C2->C2-2->C2->C1->C3(包括里面的子协程)->C4
  • C3-1-1发生异常的时候,C3-1-1->C3-1-1-1,其他不受影响
  • C3-1-1-1发生异常的时候,C3-1-1-1->C3-1-1,其他不受影响

举个例子

1、C1和C2没有关系

GlobalScope.launch { //协程C1
    GlobalScope.launch {//协程C2
        //...
    }
}

C1,C2不会互相影响,完全独立

2.C2和C3是C1的子协程,C2和C3异常会取消C1

GlobalScope.launch { //协程C1
    coroutineScoope {
         launch{}//协程C2
         launch{}//协程C3
    }
}

3.C2和C3是C1的子协程,C2和C3异常不会取消C1

GlobalScope.launch { //协程C1
    supervisorScope {
         launch{}//协程C2
         launch{}//协程C3
    }
}

如何捕获异常

1.直接用Try,Catch会有什么问题?

在java与Kotlin中,我们一般直接try,catch捕获异常

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)
}

你会发现捕获失效了,并且app crash了
我们发现try,catch无法catch住子协程的异常

发生了什么
在协程中未捕获的异常会发生什么呢? 协程最创新的功能之一就是结构化并发。 为了使结构化并发的所有功能成为可能,CoroutineScope的Job对象以及Coroutines和Child-Coroutines的Job对象形成了父子关系的层次结构。 未传播的异常(而不是重新抛出)是“在工作层次结构中传播”。 这种异常传播会导致父Job的失败,进而导致其子级所有Job的取消。

上面示例代码的job层次大概如下所示:

子协程的异常传播到协程(1)的Job,然后传播到topLevelScope(2)的Job。

传播的异常可以通过CoroutineExceptionHandler来捕获,如果没有设置,则将调用线程的未捕获异常处理程序,可能会导致退出应用
我们看出,协程有两种异常处理机制,这也是协程的异常处理比较复杂的原因

小结1

如果协程本身不使用try-catch子句自行处理异常,则不会重新抛出该异常,因此无法通过外部try-catch子句进行处理。
异常会在“Job层次结构中传播”,可以由已设置的CoroutineExceptionHandler处理。 如果未设置,则调用该线程的未捕获异常处理程序。

2.CoroutineExceptionHandler

现在我们知道,如果我们在try块中launch失败的协程,try-catch是没有用的。
因此,我们需要配置一个CoroutineExceptionHandler,我们可以将context传递给启动协程生成器。
由于CoroutineExceptionHandler是一个ContextElement,因此我们可以通过在启动子协程时将其传递给launch:

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

可以发现程序还是crash了
为什么不生效?

这是因为给子协程设置CoroutineExceptionHandler是没有效果的,我们必须给顶级协程设置,或者初始化Scope时设置才有效

// ...
val topLevelScope = CoroutineScope(Job() + coroutineExceptionHandler)
// ...

// ...
topLevelScope.launch(coroutineExceptionHandler) {
// ...

小结2

为了使CoroutineExceptionHandler起作用,必须将其设置在CoroutineScope或顶级协程中。

3.Try,Catch与CoroutineExceptionHandler对比

如上面介绍的,协程支持两种异常处理机制,那么我们应该选择哪种呢?

CoroutineExceptionHandler的官方文档提供了一些很好的答案:

“ CoroutineExceptionHandler是用于全局“全部捕获”行为的最后手段。 您无法从CoroutineExceptionHandler中的异常中恢复。 当调用处理程序时,协程已经完成,并带有相应的异常。 通常,处理程序用于记录异常,显示某种错误消息,终止和/或重新启动应用程序。

如果需要在代码的特定部分处理异常,建议在协程内部的相应代码周围使用try / catch。 这样,您可以防止协程异常完成(现在已捕获异常),重试该操作和/或采取其他任意操作:”

小结3

如果要在协程完成之前重试该操作或执行其他操作,请使用try / catch。
请记住,通过直接在协同程序中捕获异常,该异常不会在Job层次结构中传播,也不会利用结构化并发的取消功能。
而使用CoroutineExceptionHandler处理应该在协程完成后发生的逻辑。
可以看出,我们绝大多数时候应该使用CoroutineExceptionHandler

4.launch{} vs async{}

我们上面的例子都是使用launch启动协程的异常,但是launch与async的协常处理是完全不同的
下面看个例子

fun main() {

    val topLevelScope = CoroutineScope(SupervisorJob())

    topLevelScope.async {
        throw RuntimeException("RuntimeException in async coroutine")
    }

    Thread.sleep(100)
}

// 没有输出

为什么这里不会抛出异常?
我们先要了解下launch与async的区别

从launch开始的协程的返回类型是Job,它只是协程的一种表示形式,没有返回值。
如果我们需要协程的某些结果,则必须使用async,它返回Deferred,这是一种特殊的Job,另外还保存一个结果值。 如果异步协程失败,则将该异常封装在Deferred返回类型中,并在我们调用suspend函数.await()来检索其结果值时将其重新抛出。

因此,我们可以使用try-catch子句将对.await()的调用括起来。

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处理,甚至传递给线程的未捕获异常处理程序,即使不对其调用.await(),如以下示例所示:

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协程中未捕获的异常会立即在作业层次结构中传播。
但是,如果顶层Coroutine是从launch启动的,则异常将由CoroutineExceptionHandler处理或传递给线程的未捕获异常处理程序。
如果顶级协程以async方式启动,则异常封装在Deferred返回类型中,并在调用.await()时重新抛出。

5.coroutineScope异常处理特性

文章开头我们举了个例子,失败的协程将其异常传播到Job层次结构中,而不是重新抛出该异常,因此,外部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函数将重新抛出其失败的协程的异常,因此我们可以相应地设置异常处理逻辑。

5.小结5

范围函数coroutineScope {}重新抛出其失败的子协程的异常,而不是将其传播到Job层次结构中,这使我们能够使用try-catch处理失败的协程的异常

6.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)
}

现在,在这里了解异常处理至关重要的一点是,supervisorScope是一个必须独立处理异常的新的独立子域。
它不会像coroutineScope那样重新抛出失败的协程的异常,也不会将异常传播到其父级– topLevelScope作业。

要理解的另一件至关重要的事情是,异常只会向上传播,直到它们到达顶级范围或SupervisorJob。 这意味着job2和job3现在是顶级协程。
这也意味着我们可以为它们添加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()时被重新抛出
这也是为什么viewModelScope中的async需要调用await才会抛出异常的原因

小结6

范围函数supervisorScope {}Job层次结构中添加了一个新的独立子范围,并将SupervisorJob作为这个scope的'job'。
这个新作用域不会在“Job层次结构”中传播其异常,因此它必须自行处理其异常。
直接从supervisorScope启动的协程是顶级协程。
顶级协程与子协程在使用launch()async()启动时的行为有所不同,此外,还可以在它们中安装CoroutineExceptionHandlers

协程异常处理封装

如上文所说,在大多数时候,CoroutineExceptionHandler是一个更好的选择

如我们所知,协程最大的优点是可以使用同步的方法写异步代码,CoroutineExceptionHandler有以下缺点
1.将异常处理代码与协程代码分隔开了,看上去不是同步代码
2.每次使用都要新建局部变量,不够优雅

我们可以对CoroutineExceptionHandler进行封装,利用kotlin扩展函数,实现类似RxJava的调用效果
最后调用效果如下

fun fetch() {
        viewModelScope.rxLaunch<String> {
            onRequest = {
                //网络请求
                resposity.getData()
            }
            onSuccess = {
                //成功回调
            }
            onError = {
                //失败回调
            }
        }
    }

代码实现

主要利用kotlin扩展函数及DSL语法,封装协程异常处理,达到类似RxJava调用的效果

fun <T> CoroutineScope.rxLaunch(init: CoroutineBuilder<T>.() -> Unit) {
    val result = CoroutineBuilder<T>().apply(init)
    val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception ->
        result.onError?.invoke(exception)
    }
    launch(coroutineExceptionHandler) {
        val res: T? = result.onRequest?.invoke()
        res?.let {
            result.onSuccess?.invoke(it)
        }
    }
}

class CoroutineBuilder<T> {
    var onRequest: (suspend () -> T)? = null
    var onSuccess: ((T) -> Unit)? = null
    var onError: ((Throwable) -> Unit)? = null
}

如上即是一个简单封装,可实现上面演示的目标效果
将请示,成功,失败分类展示,结构更加清晰,同时不需要写CoroutineExceptionHandler局部变量,更为优雅简洁

参考资料

[译] 关于 Kotlin Coroutines, 你可能会犯的 7 个错误
安卓-kotlin协程的异常处理机制分析
why-exception-handling-with-kotlin-coroutines-is-so-hard-and-how-to-successfully-master-it

本文正在参与「掘金 2021 春招闯关活动」, 点击查看 活动详情