破解 Kotlin 协程(4) - 异常处理篇

关键词:Kotlin 协程 异常处理java

异步代码的异常处理一般都比较让人头疼,而协程则再一次展示了它的威力。git

1. 引子

咱们在前面一篇文章当中提到了这样一个例子:github

typealias Callback = (User) -> Unit

fun getUser(callback: Callback){
    ...
}

复制代码

咱们一般会定义这样的回调接口来实现异步数据的请求,咱们能够很方便的将它转换成协程的接口:bash

suspend fun getUserCoroutine() = suspendCoroutine<User> {
    continuation ->
    getUser {
        continuation.resume(it)
    }
}

复制代码

并最终交给按钮点击事件或者其余事件去触发这个异步请求:并发

getUserBtn.setOnClickListener {
    GlobalScope.launch(Dispatchers.Main) {
        userNameView.text = getUserCoroutine().name
    }
}

复制代码

那么问题来了,既然是请求,总会有失败的情形,而咱们这里并无对错误的处理,接下来咱们就完善这个例子。异步

2. 添加异常处理逻辑

首先咱们加上异常回调接口函数:jvm

interface Callback<T> {
    fun onSuccess(value: T)

    fun onError(t: Throwable)
}

复制代码

接下来咱们在改造一下咱们的 getUserCoroutineasync

suspend fun getUserCoroutine() = suspendCoroutine<User> { continuation ->
    getUser(object : Callback<User> {
        override fun onSuccess(value: User) {
            continuation.resume(value)
        }

        override fun onError(t: Throwable) {
            continuation.resumeWithException(t)
        }
    })
}

复制代码

你们能够看到,咱们彷佛就是彻底把 Callback 转换成了一个 Continuation,在调用的时候咱们只须要:ide

GlobalScope.launch(Dispatchers.Main) {
    try {
        userNameView.text = getUserCoroutine().name
    } catch (e: Exception) {
        userNameView.text = "Get User Error: $e"
    }
}

复制代码

是的,你没看错,一个异步的请求异常,咱们只须要在咱们的代码中捕获就能够了,这样作的好处就是,请求的全流程异常均可以在一个 try ... catch ... 当中捕获,那么咱们能够说真正作到了把异步代码变成了同步的写法。函数

若是你一直在用 RxJava 处理这样的逻辑,那么你的请求接口多是这样的:

fun getUserObservable(): Single<User> {
    return Single.create<User> { emitter ->
        getUser(object : Callback<User> {
            override fun onSuccess(value: User) {
                emitter.onSuccess(value)
            }

            override fun onError(t: Throwable) {
                emitter.onError(t)
            }
        })
    }
}

复制代码

调用时大概是这样的:

getUserObservable()
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe ({ user ->
            userNameView.text = user.name
        }, {
            userNameView.text = "Get User Error: $it"
        })
        
复制代码

其实你很容易就能发如今这里 RxJava 作的事儿跟协程的目的是同样的,只不过协程用了一种更天然的方式。

也许你已经对 RxJava 很熟悉而且感到很天然,但相比之下,RxJava 的代码比协程的复杂度更高,更让人费解,这一点咱们后面的文章中也会持续用例子来讲明这一点。

3. 全局异常处理

线程也好、RxJava 也好,都有全局处理异常的方式,例如:

fun main() {
    Thread.setDefaultUncaughtExceptionHandler {t: Thread, e: Throwable ->
        //handle exception here
        println("Thread '${t.name}' throws an exception with message '${e.message}'")
    }

    throw ArithmeticException("Hey!")
}

复制代码

咱们能够为线程设置全局的异常捕获,固然也能够为 RxJava 来设置全局异常捕获:

RxJavaPlugins.setErrorHandler(e -> {
        //handle exception here
        println("Throws an exception with message '${e.message}'")
});

复制代码

协程显然也能够作到这一点。相似于经过 Thread.setUncaughtExceptionHandler 为线程设置一个异常捕获器,咱们也能够为每个协程单独设置 CoroutineExceptionHandler,这样协程内部未捕获的异常就能够经过它来捕获:

private suspend fun main(){
    val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
        log("Throws an exception with message: ${throwable.message}")
    }

    log(1)
    GlobalScope.launch(exceptionHandler) {
        throw ArithmeticException("Hey!")
    }.join()
    log(2)
}

复制代码

运行结果:

19:06:35:087 [main] 1
19:06:35:208 [DefaultDispatcher-worker-1 @coroutine#1] Throws an exception with message: Hey!
19:06:35:211 [DefaultDispatcher-worker-1 @coroutine#1] 2
复制代码

CoroutineExceptionHandler 居然也是一个上下文,协程的这个上下文可真是灵魂通常的存在,这却是一点儿也不让人感到意外。

固然,这并不算是一个全局的异常捕获,由于它只能捕获对应协程内未捕获的异常,若是你想作到真正的全局捕获,在 Jvm 上咱们能够本身定义一个捕获类实现:

class GlobalCoroutineExceptionHandler: CoroutineExceptionHandler {
    override val key: CoroutineContext.Key<*> = CoroutineExceptionHandler

    override fun handleException(context: CoroutineContext, exception: Throwable) {
        println("Coroutine exception: $exception")
    }
}

复制代码

而后在 classpath 中建立 META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler,文件名实际上就是 CoroutineExceptionHandler 的全类名,文件内容就写咱们的实现类的全类名:

com.bennyhuo.coroutines.sample2.exceptions.GlobalCoroutineExceptionHandler
复制代码

这样协程中没有被捕获的异常就会最终交给它处理。

Jvm 上全局 CoroutineExceptionHandler 的配置,本质上是对 ServiceLoader 的应用,以前咱们在讲 Dispatchers.Main 的时候提到过,Jvm 上它的实现也是经过 ServiceLoader 来加载的。

须要明确的一点是,经过 async 启动的协程出现未捕获的异常时会忽略 CoroutineExceptionHandler,这与 launch 的设计思路是不一样的。

4. 异常传播

异常传播还涉及到协程做用域的概念,例如咱们启动协程的时候一直都是用的 GlobalScope,意味着这是一个独立的顶级协程做用域,此外还有 coroutineScope { ... } 以及 supervisorScope { ... }

  • 经过 GlobeScope 启动的协程单独启动一个协程做用域,内部的子协程听从默认的做用域规则。经过 GlobeScope 启动的协程“自成一派”。
  • coroutineScope 是继承外部 Job 的上下文建立做用域,在其内部的取消操做是双向传播的,子协程未捕获的异常也会向上传递给父协程。它更适合一系列对等的协程并发的完成一项工做,任何一个子协程异常退出,那么总体都将退出,简单来讲就是”一损俱损“。这也是协程内部再启动子协程的默认做用域。
  • supervisorScope 一样继承外部做用域的上下文,但其内部的取消操做是单向传播的,父协程向子协程传播,反过来则否则,这意味着子协程出了异常并不会影响父协程以及其余兄弟协程。它更适合一些独立不相干的任务,任何一个任务出问题,并不会影响其余任务的工做,简单来讲就是”自食其果“,例如 UI,我点击一个按钮出了异常,其实并不会影响手机状态栏的刷新。须要注意的是,supervisorScope 内部启动的子协程内部再启动子协程,如无明确指出,则遵照默认做用域规则,也即 supervisorScope 只做用域其直接子协程。

这么说仍是比较抽象,所以咱们拿一些例子来分析一下:

suspend fun main() {
    log(1)
    try {
        coroutineScope { //①
            log(2)
            launch { // ②
                log(3)
                launch { // ③ 
                    log(4)
                    delay(100)
                    throw ArithmeticException("Hey!!")
                }
                log(5)
            }
            log(6)
            val job = launch { // ④
                log(7)
                delay(1000)
            }
            try {
                log(8)
                 job.join()
                log("9")
            } catch (e: Exception) {
                log("10. $e")
            }
        }
        log(11)
    } catch (e: Exception) {
        log("12. $e")
    }
    log(13)
}

复制代码

这例子稍微有点儿复杂,但也不难理解,咱们在一个 coroutineScope 当中启动了两个协程 ②④,在 ② 当中启动了一个子协程 ③,做用域直接建立的协程记为①。那么 ③ 当中抛异常会发生什么呢?咱们先来看下输出:

11:37:36:208 [main] 1
11:37:36:255 [main] 2
11:37:36:325 [DefaultDispatcher-worker-1] 3
11:37:36:325 [DefaultDispatcher-worker-1] 5
11:37:36:326 [DefaultDispatcher-worker-3] 4
11:37:36:331 [main] 6
11:37:36:336 [DefaultDispatcher-worker-1] 7
11:37:36:336 [main] 8
11:37:36:441 [DefaultDispatcher-worker-1] 10. kotlinx.coroutines.JobCancellationException: ScopeCoroutine is cancelling; job=ScopeCoroutine{Cancelling}@2bc92d2f
11:37:36:445 [DefaultDispatcher-worker-1] 12. java.lang.ArithmeticException: Hey!!
11:37:36:445 [DefaultDispatcher-worker-1] 13
复制代码

注意两个位置,一个是 10,咱们调用 join,收到了一个取消异常,在协程当中支持取消的操做的suspend方法在取消时会抛出一个 CancellationException,这相似于线程中对 InterruptException 的响应,遇到这种状况表示 join 调用所在的协程已经被取消了,那么这个取消到底是怎么回事呢?

原来协程 ③ 抛出了未捕获的异常,进入了异常完成的状态,它与父协程 ② 之间遵循默认的做用域规则,所以 ③ 会通知它的父协程也就是 ② 取消,② 根据做用域规则通知父协程 ① 也就是整个做用域取消,这是一个自下而上的一次传播,这样身处 ① 当中的 job.join 调用就会抛异常,也就是 10 处的结果了。若是不是很理解这个操做,想一下咱们说到的,coroutineScope 内部启动的协程就是“一损俱损”。实际上因为父协程 ① 被取消,协程④ 也不能幸免,若是你们有兴趣的话,也能够对 ④ 当中的 delay进行捕获,同样会收获一枚取消异常。

还有一个位置就是 12,这个是咱们对 coroutineScope 总体的一个捕获,若是 coroutineScope 内部觉得异常而结束,那么咱们是能够对它直接 try ... catch ... 来捕获这个异常的,这再一次代表协程把异步的异常处理到同步代码逻辑当中。

那么若是咱们把 coroutineScope 换成 supervisorScope,其余不变,运行结果会是怎样呢?

11:52:48:632 [main] 1
11:52:48:694 [main] 2
11:52:48:875 [main] 6
11:52:48:892 [DefaultDispatcher-worker-1 @coroutine#1] 3
11:52:48:895 [DefaultDispatcher-worker-1 @coroutine#1] 5
11:52:48:900 [DefaultDispatcher-worker-3 @coroutine#3] 4
11:52:48:905 [DefaultDispatcher-worker-2 @coroutine#2] 7
11:52:48:907 [main] 8
Exception in thread "DefaultDispatcher-worker-3 @coroutine#3" java.lang.ArithmeticException: Hey!!
	at com.bennyhuo.coroutines.sample2.exceptions.ScopesKt$main$2$1$1.invokeSuspend(Scopes.kt:17)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	at kotlinx.coroutines.DispatchedTask.run(Dispatched.kt:238)
	at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:594)
	at kotlinx.coroutines.scheduling.CoroutineScheduler.access$runSafely(CoroutineScheduler.kt:60)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:742)
11:52:49:915 [DefaultDispatcher-worker-3 @coroutine#2] 9
11:52:49:915 [DefaultDispatcher-worker-3 @coroutine#2] 11
11:52:49:915 [DefaultDispatcher-worker-3 @coroutine#2] 13
复制代码

咱们能够看到,1-8 的输出其实没有本质区别,顺序上的差别是线程调度的先后形成的,并不会影响协程的语义。差异主要在于 9 与 十、11与12的区别,若是把 scope 换成 supervisorScope,咱们发现 ③ 的异常并无影响做用域以及做用域内的其余子协程的执行,也就是咱们所说的“自食其果”。

这个例子其实咱们再稍作一些改动,为 ② 和 ③ 增长一个 CoroutineExceptionHandler,就能够证实咱们前面提到的另一个结论:

首先咱们定义一个 CoroutineExceptionHandler,咱们经过上下文获取一下异常对应的协程的名字:

val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
    log("${coroutineContext[CoroutineName]} $throwable")
}

复制代码

接着,基于前面的例子咱们为 ② 和 ③ 添加 CoroutineExceptionHandler 和名字:

...
supervisorScope { //①
    log(2)
    launch(exceptionHandler + CoroutineName("②")) { // ②
        log(3)
        launch(exceptionHandler + CoroutineName("③")) { // ③
            log(4)
...

复制代码

再运行这段程序,结果就比较有意思了:

...
07:30:11:519 [DefaultDispatcher-worker-1] CoroutineName(②) java.lang.ArithmeticException: Hey!!
...
复制代码

咱们发现触发的 CoroutineExceptionHandler 居然是协程 ② 的,意外吗?不意外,由于咱们前面已经提到,对于 supervisorScope 的子协程 (例如 ②)的子协程(例如 ③),若是没有明确指出,它是遵循默认的做用于规则的,也就是 coroutineScope 的规则了,出现未捕获的异常会尝试传递给父协程并尝试取消父协程。

究竟使用什么 Scope,你们本身根据实际状况来肯定,我给出一些建议:

  • 对于没有协程做用域,但须要启动协程的时候,适合用 GlobalScope
  • 对于已经有协程做用域的状况(例如经过 GlobalScope 启动的协程体内),直接用协程启动器启动
  • 对于明确要求子协程之间相互独立不干扰时,使用 supervisorScope
  • 对于经过标准库 API 建立的协程,这样的协程比较底层,没有 Job、做用域等概念的支撑,例如咱们前面提到过 suspend main 就是这种状况,对于这种状况优先考虑经过 coroutineScope 建立做用域;更进一步,你们尽可能不要直接使用标准库 API,除非你对 Kotlin 的协程机制很是熟悉。

固然,对于可能出异常的状况,请你们尽可能作好异常处理,不要将问题复杂化。

5. join 和 await

前面咱们举例子一直用的是 launch,启动协程其实经常使用的还有 asyncactorproduce,其中 actorlaunch 的行为相似,在未捕获的异常出现之后,会被当作为处理的异常抛出,就像前面的例子那样。而 asyncproduce 则主要是用来输出结果的,他们内部的异常只在外部消费他们的结果时抛出。这两组协程的启动器,你也能够认为分别是“消费者”和“生产者”,消费者异常当即抛出,生产者只有结果消费时抛出异常。

actorproduce 这两个 API 目前处于比较微妙的境地,可能会被废弃或者后续提供替代方案,不建议你们使用,咱们在这里就不展开细讲了。

那么消费结果指的是什么呢?对于 async 来说,就是 await,例如:

suspend fun main() {
    val deferred = GlobalScope.async<Int> { 
        throw ArithmeticException()
    }
    try {
        val value = deferred.await()
        log("1. $value")
    } catch (e: Exception) {
        log("2. $e")
    }
}

复制代码

这个从逻辑上很好理解,咱们调用 await 时,指望 deferred 可以给咱们提供一个合适的结果,但它由于出异常,没有办法作到这一点,所以只好给咱们丢出一个异常了。

13:25:14:693 [main] 2. java.lang.ArithmeticException
复制代码

咱们本身实现的 getUserCoroutine 也属于相似的状况,在获取结果时,若是请求出了异常,咱们就只能拿到一个异常,而不是正常的结果。相比之下,join 就有趣的多了,它只关注是否执行完,至因而由于什么完成,它不关心,所以若是咱们在这里替换成 join

suspend fun main() {
    val deferred = GlobalScope.async<Int> {
        throw ArithmeticException()
    }
    try {
        deferred.join()
        log(1)
    } catch (e: Exception) {
        log("2. $e")
    }
}

复制代码

咱们就会发现,异常被吞掉了!

13:26:15:034 [main] 1
复制代码

若是例子当中咱们用 launch 替换 asyncjoin 处仍然不会有任何异常抛出,仍是那句话,它只关心有没有完成,至于怎么完成的它不关心。不一样之处在于, launch 中未捕获的异常与 async 的处理方式不一样,launch 会直接抛出给父协程,若是没有父协程(顶级做用域中)或者处于 supervisorScope 中父协程不响应,那么就交给上下文中指定的 CoroutineExceptionHandler处理,若是没有指定,那传给全局的 CoroutineExceptionHandler 等等,而 async 则要等 await 来消费。

无论是哪一个启动器,在应用了做用域以后,都会按照做用域的语义进行异常扩散,进而触发相应的取消操做,对于 async 来讲就算不调用 await 来获取这个异常,它也会在 coroutineScope 当中触发父协程的取消逻辑,这一点请你们注意。

6. 小结

这一篇咱们讲了协程的异常处理。这一起稍微显得有点儿复杂,但仔细理一下主要有三条线:

  1. 协程内部异常处理流程:launch 会在内部出现未捕获的异常时尝试触发对父协程的取消,可否取消要看做用域的定义,若是取消成功,那么异常传递给父协程,不然传递给启动时上下文中配置的 CoroutineExceptionHandler 中,若是没有配置,会查找全局(JVM上)的 CoroutineExceptionHandler 进行处理,若是仍然没有,那么就将异常交给当前线程的 UncaughtExceptionHandler 处理;而 async 则在未捕获的异常出现时一样会尝试取消父协程,但无论是否可以取消成功都不会后其余后续的异常处理,直到用户主动调用 await 时将异常抛出。
  2. 异常在做用域内的传播:当协程出现异常时,会根据当前做用域触发异常传递,GlobalScope 会建立一个独立的做用域,所谓“自成一派”,而 在 coroutineScope 当中协程异常会触发父协程的取消,进而将整个协程做用域取消掉,若是对 coroutineScope 总体进行捕获,也能够捕获到该异常,所谓“一损俱损”;若是是 supervisorScope,那么子协程的异常不会向上传递,所谓“自食其果”。
  3. join 和 await 的不一样:join 只关心协程是否执行完,await 则关心运行的结果,所以 join 在协程出现异常时也不会抛出该异常,而 await 则会;考虑到做用域的问题,若是协程抛异常,可能会致使父协程的取消,所以调用 join 时尽管不会对协程自己的异常进行抛出,但若是 join 调用所在的协程被取消,那么它会抛出取消异常,这一点须要留意。

若是你们能把这三点理解清楚了,那么协程的异常处理能够说就很是清晰了。文中由于异常传播的缘由,咱们提到了取消,但没有展开详细讨论,后面咱们将会专门针对取消输出一篇文章,帮助你们加深理解。

附加说明

join 在父协程被取消时有一个 bug 会致使不抛出取消异常,我在准备本文时发现该问题,目前已经提交到官方并获得了修复,预计合入到 1.2.1 发版,你们有兴趣能够查看这个 issue:No CancellationException thrown when join on a crashed Job

固然,这个 bug 对于生成环境的影响很小,你们也不要担忧。


欢迎关注 Kotlin 中文社区!

中文官网:www.kotlincn.net/

中文官方博客:www.kotliner.cn/

公众号:Kotlin

知乎专栏:Kotlin

CSDN:Kotlin中文社区

掘金:Kotlin中文社区

简书:Kotlin中文社区

相关文章
相关标签/搜索