美文网首页 移动 前端 Python Android Java
破解 Kotlin 协程(4) - 异常处理篇

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

作者: Kotlin中文社区 | 来源:发表于2019-05-27 14:45 被阅读0次

    关键词:Kotlin 协程 异常处理

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

    1. 引子

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

    typealias Callback = (User) -> Unit
    
    fun getUser(callback: Callback){
        ...
    }
    

    我们通常会定义这样的回调接口来实现异步数据的请求,我们可以很方便的将它转换成协程的接口:

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

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

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

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

    2. 添加异常处理逻辑

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

    interface Callback<T> {
        fun onSuccess(value: T)
    
        fun onError(t: Throwable)
    }
    

    接下来我们在改造一下我们的 getUserCoroutine

    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,在调用的时候我们只需要:

    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 与 10、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 中文社区!

    中文官网:https://www.kotlincn.net/

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

    公众号:Kotlin

    知乎专栏:Kotlin

    CSDN:Kotlin中文社区

    掘金:Kotlin中文社区

    简书:Kotlin中文社区

    开发者头条:Kotlin中文社区

    相关文章

      网友评论

        本文标题:破解 Kotlin 协程(4) - 异常处理篇

        本文链接:https://www.haomeiwen.com/subject/sjdhtctx.html