给出下面代码:
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 启动的协程的态度是不一样的,它的源码注释里写明了的,简单的认为它会吃掉异常是会踩坑的。
翻译出来就是,如果是 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.") } }