给出下面代码:

lifecycleScope.launch(Dispatchers.IO) {
    val task1 = async {
        throw RuntimeException("task1 failed")
    }
    val task2 = async {
        throw RuntimeException("task2 failed")
    }
    try {
        task1.await()
    } catch (e: Throwable){
        Log.i("test", "catch task1: $e")
    }
    Log.i("test", "is coroutine active: $isActive")
    try {
        task2.await()
    } catch (e: Throwable){
        Log.i("test", "catch task2: $e")
    }
    Log.i("test", "scope end.")
}

问:app 会产生什么?输出的日志是怎姿态的?为什么?

……

……

……

……

……

……

……

……

……

答:app 会 crash,输出日志为


I/test: catch task1: java.lang.RuntimeException: task1 failed
I/test: is coroutine active: false
I/test: catch task2: kotlinx.coroutines.JobCancellationException: Parent job is Cancelling; job=DeferredCoroutine{Cancelling}
I/test: scope end.

魔幻吗?

那咱们就来剖析下为啥结果是这个姿态的。

协程有一个很根底的设定:默许情况下,反常会往外层 scope 抛,用以马上撤销外层 scope 内的其它的子 job。

在上面的比如中,假设:lifecycleScope.launch 创立的子 scope 为 A。task1 用 async 创立 scope A 的子 scope 为 B。task2 用 async 创立 scope A 的子 scope 为 C。

当 scope B 产生反常,scope B 会将反常抛给 scope A,scope A 会 cancel 掉 task2 和自己,再把反常抛给 lifecycleScope,由于 lifecycleScope 没有 CoroutineExceptionHandler 并且 scope A 是通过 launch 发动的,所以 crash 就产生了。

那怎么打断反常的这个传达链呢?

答案便是使用 SupervisorJob,或许用根据它的 supervisorScope。它不会把反常往上抛,也不会撤销掉其它的子 job。但是,SupervisorJob 对 launch 和 async 发动的协程的态度是不一样的,它的源码注释里写明晰的,简略的认为它会吃掉反常是会踩坑的。

来个面试题,看看你对 kotlin coroutine掌握得如何?

翻译出来便是,假如是 launch 发动的子协程,是需求 CoroutineExceptionHandler 配合处理的,假如是 async 发动的协程,便是真的不抛,比及 Deferred.await 时再抛。

所以,在上面的代码中,虽然 lifecycleScope 有用到 SupervisorJob,但反常从 scopeA 往上传时,由于没有 CoroutineExceptionHandler,所以跪了。

那么为什么 async 要往上抛反常,导致 await 的 try catch 还需求 supervisorScope 的配合?感觉有点反人类?

想象一下下面的场合:

lifecycleScope.launch {
    val task1 = async { "十分耗时的操作,但没有反常" }
    val task2 = async { throw RuntimeException("") }
    val result1 = task1.await()
    val result2 = task2.await()
}

由于 task2 有反常,所以整个协程必定会失利。假如等 await 时才跑错误, 那么就需求等耗时的 task1 履行完成,轮到 task 的 await 调用时,反常才能跑出来,虽然也没啥问题,便是白白耗费了 task1 的履行。

而依据当前的设计,task2 抛出反常,那么外层 scope 就会把 task1 也给撤销了,整个 scope 也就履行完毕了。async 源码里说到的原因是为了 structured concurrency,也是希望使用者更多的重视 scope 以及 scope 内各个任务的关联联系吧。不过这坑的确有点让人有时摸不着头脑,或许今后就变了也说不定。

剩下一个问题是,task1 失利后就往上抛吗?为啥 catch task1 后还有日志打印出来?

其实上面现已说到了,反常抛给 scope A 后,它会 cancel 掉自己,再往上抛,而 cancel 掉自己并不是强制终止掉协程的履行,而是先改变状况为 cancelling,所以日志中 isActive 现已变成 false 了,第二个反常也不是 task2 的反常,而是 await 本身抛出的 CancellationException。这儿告诉咱们要注意两点:

  1. try catch 时假如是 CancellationException,要记得 rethrow。
  2. 一些循环、耗时的点,要记得用 isActive 或许 ensureActive 查看,不要写出不能正常 cancel 的协程。像 delay 等 api,官方现已做好了这方面的查看,极大地方便了开发者。这个线程 interrupted 相关知识点是同一个道理。

了解了各种坑点以及背后的原因,咱们就可以把协程用得飞起了。最终,修复文章最初说到的问题,便是简略包个 supervisorScope 就行啦。

lifecycleScope.launch(Dispatchers.IO) {
    supervisorScope {
        val task1 = async {
            throw RuntimeException("task1 failed")
        }
        val task2 = async {
            throw RuntimeException("task2 failed")
        }
        try {
            task1.await()
        } catch (e: Throwable){
            Log.i("test", "catch task1: $e")
        }
        Log.i("test", "is coroutine active: $isActive")
        try {
            task2.await()
        } catch (e: Throwable){
            Log.i("test", "catch task2: $e")
        }
        Log.i("test", "scope end.")
    }
}

最终,引荐下我的大众号,快来重视下:

来个面试题,看看你对 kotlin coroutine掌握得如何?