首页
学习
活动
专区
圈层
工具
发布

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

关键词:Kotlin 协程 异常处理

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

1. 引子

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

代码语言:javascript
代码运行次数:0
复制
typealias Callback = (User) -> Unit

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

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

代码语言:javascript
代码运行次数:0
复制
suspend fun getUserCoroutine() = suspendCoroutine<User> {
    continuation ->
    getUser {
        continuation.resume(it)
    }
}

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

代码语言:javascript
代码运行次数:0
复制
getUserBtn.setOnClickListener {
    GlobalScope.launch(Dispatchers.Main) {
        userNameView.text = getUserCoroutine().name
    }
}

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

2. 添加异常处理逻辑

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

代码语言:javascript
代码运行次数:0
复制
interface Callback<T> {
    fun onSuccess(value: T)

    fun onError(t: Throwable)
}

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

代码语言:javascript
代码运行次数:0
复制
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,在调用的时候我们只需要:

代码语言:javascript
代码运行次数:0
复制
GlobalScope.launch(Dispatchers.Main) {
    try {
        userNameView.text = getUserCoroutine().name
    } catch (e: Exception) {
        userNameView.text = "Get User Error: $e"
    }
}

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

如果你一直在用 RxJava 处理这样的逻辑,那么你的请求接口可能是这样的:

代码语言:javascript
代码运行次数:0
复制
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)
            }
        })
    }
}

调用时大概是这样的:

代码语言:javascript
代码运行次数:0
复制
getUserObservable()
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe ({ user ->
            userNameView.text = user.name
        }, {
            userNameView.text = "Get User Error: $it"
        })

其实你很容易就能发现在这里 RxJava 做的事儿跟协程的目的是一样的,只不过协程用了一种更自然的方式。

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

3. 全局异常处理

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

代码语言:javascript
代码运行次数:0
复制
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 来设置全局异常捕获:

代码语言:javascript
代码运行次数:0
复制
RxJavaPlugins.setErrorHandler(e -> {
        //handle exception here
        println("Throws an exception with message '${e.message}'")
});

协程显然也可以做到这一点。类似于通过 Thread.setUncaughtExceptionHandler 为线程设置一个异常捕获器,我们也可以为每一个协程单独设置 CoroutineExceptionHandler,这样协程内部未捕获的异常就可以通过它来捕获:

代码语言:javascript
代码运行次数:0
复制
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)
}

运行结果:

代码语言:javascript
代码运行次数:0
复制
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 上我们可以自己定义一个捕获类实现:

代码语言:javascript
代码运行次数:0
复制
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 的全类名,文件内容就写我们的实现类的全类名:

代码语言:javascript
代码运行次数:0
复制
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 只作用域其直接子协程。

这么说还是比较抽象,因此我们拿一些例子来分析一下:

代码语言:javascript
代码运行次数:0
复制
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 当中启动了两个协程 ②④,在 ② 当中启动了一个子协程 ③,作用域直接创建的协程记为①。那么 ③ 当中抛异常会发生什么呢?我们先来看下输出:

代码语言:javascript
代码运行次数:0
复制
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,其他不变,运行结果会是怎样呢?

代码语言:javascript
代码运行次数:0
复制
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,我们通过上下文获取一下异常对应的协程的名字:

代码语言:javascript
代码运行次数:0
复制
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
    log("${coroutineContext[CoroutineName]} $throwable")
}

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

代码语言:javascript
代码运行次数:0
复制
...
supervisorScope { //①
    log(2)
    launch(exceptionHandler + CoroutineName("②")) { // ②
        log(3)
        launch(exceptionHandler + CoroutineName("③")) { // ③
            log(4)
...

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

代码语言:javascript
代码运行次数:0
复制
...
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,启动协程其实常用的还有 async、 actor 和 produce,其中 actor 和 launch 的行为类似,在未捕获的异常出现以后,会被当做为处理的异常抛出,就像前面的例子那样。而 async 和 produce 则主要是用来输出结果的,他们内部的异常只在外部消费他们的结果时抛出。这两组协程的启动器,你也可以认为分别是“消费者”和“生产者”,消费者异常立即抛出,生产者只有结果消费时抛出异常。

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

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

代码语言:javascript
代码运行次数:0
复制
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 能够给我们提供一个合适的结果,但它因为出异常,没有办法做到这一点,因此只好给我们丢出一个异常了。

代码语言:javascript
代码运行次数:0
复制
13:25:14:693 [main] 2. java.lang.ArithmeticException

我们自己实现的 getUserCoroutine 也属于类似的情况,在获取结果时,如果请求出了异常,我们就只能拿到一个异常,而不是正常的结果。相比之下, join 就有趣的多了,它只关注是否执行完,至于是因为什么完成,它不关心,因此如果我们在这里替换成 join:

代码语言:javascript
代码运行次数:0
复制
suspend fun main() {
    val deferred = GlobalScope.async<Int> {
        throw ArithmeticException()
    }
    try {
        deferred.join()
        log(1)
    } catch (e: Exception) {
        log("2. $e")
    }
}

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

代码语言:javascript
代码运行次数:0
复制
13:26:15:034 [main] 1

如果例子当中我们用 launch 替换 async, join 处仍然不会有任何异常抛出,还是那句话,它只关心有没有完成,至于怎么完成的它不关心。不同之处在于, 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 https://github.com/Kotlin/kotlinx.coroutines/issues/1123。

当然,这个 bug 对于生成环境的影响很小,大家也不要担心。

下一篇
举报
领券