前言
如果说协程的挂起函数、结构化并发、Flow
等让你觉得开发很便利,那协程的异常处理就是很多开发者不想使用协程的原因,因为它太复杂了。
本篇文章就先以使用的角度来看,不分析原理,来看看如何处理协程的异常。
正文
在协程当中,异常可以分为2大类:一类是取消异常即CancellationException
,一类是其他异常。为什么这么分类,因为这2种异常的处理方式是不一样的。
当协程任务被取消的时候,它的内部会产生一个CancellationException
异常,而协程的结构化取消有个特点就是:如果取消了父协程,其子协程也会被取消,而实现这个结构化取消的关键点就是这个CacnellationException
。
由于协程异常处理比较麻烦,我们先从常见场景逐渐分析。
协程的取消
取消协程最简单的方法,在前面介绍Job
时我们就说过,就是cancel()
方法,但是有时这个cancel()
方法却无响应。
cancel()
方法需要内部配合
我们直接看下面代码:
/**
* 该例子中,[cancel]方法无法取消[Job],程序会一直执行
* */
fun main() = runBlocking {
val job = launch(Dispatchers.Default) {
var i = 0
while (true){
Thread.sleep(500)
i ++
println("i = $i")
}
}
delay(2000)
//取消job
job.cancel()
//等待job执行完成
job.join()
println("End")
}
在代码中,我们启动了一个协程用于循环打印,但是当我们调用了cancel
方法后,会发现打印会一直执行,无法停止。
所以协程的cancel()
方法想要生效,需要协程内部进行配合。
第一个方法就是判断协程的状态,看其是否是活跃状态,在Job
文章我们知道,Job
是协程的句柄,可以监控协程状态,所以把while(true)
死循环判断改成while(isActive)
,便可以成功取消协程:
//可以正常被[cancel]
fun main() = runBlocking {
val job = launch(Dispatchers.Default) {
var i = 0
//这里改成判断是否为Active状态
while (isActive){
Thread.sleep(500)
i ++
println("i = $i")
}
}
delay(2000)
//取消job
job.cancel()
//等待job执行完成
job.join()
println("End")
}
第二个方法是利用挂起函数的特性,对于Kotlin提供的挂起函数,它们是可以自动响应协程的取消的。比如我们可以把线程的休眠Thread.sleep(500)
改成delay(500)
,协程也是可以被正常取消的:
/**
* Kotlin的挂起函数,是可以自动响应协程的取消的
* */
fun main() = runBlocking {
val job = launch(Dispatchers.Default) {
var i = 0
while (true){
//这里改成delay
delay(500)
i ++
println("i = $i")
}
}
delay(2000)
//取消job
job.cancel()
//等待job执行完成
job.join()
println("End")
}
delay()
方法会挂起当前协程一段时间后,然后恢复,同时它是可以被取消的,当它所在的协程Job
被取消或者完成了,该方法会立即返回CancellationException
异常。至于为什么该异常没有导致程序崩溃,以及delay
函数的底层实现suspendCancelableCoroutine
的具体原理,后面文章再说。
不要打破结构化的父子关系
之前文章我们说过,协程和协程之间是可以存在父子关系的,当父协程调用cancel
方法进行取消后,可以让子协程也取消任务,这种就是结构化的关系。
所以我们在日常使用协程时,对于协程的父子关系一定要注意,比如下面代码:
/**
* 先创建一个固定数量线程的线程池,即[ExecutorService],然后通过[asCoroutineDispatcher]
* 转换为一个[CoroutineDispatcher]
*
* @param nThreads 线程池的线程数量
* @param Runnable 当需要使用线程时,用该lambda中的方法创建,这里设置为守护线程,防止退出
* */
val fixedDispatcher = Executors.newFixedThreadPool(2){
Thread(it).apply { isDaemon = false }
}.asCoroutineDispatcher()
fun main() = runBlocking {
//父协程
val parentJob = launch(fixedDispatcher) {
//开启一个子协程,但是传递的上下文是一个新的句柄
launch(Job()) {
var i = 0
while (isActive){
Thread.sleep(500L)
i ++
logX("First i = $i")
}
}
//开启子协程,使用父协程的CoroutineScope
launch {
var i = 0
while (isActive){
Thread.sleep(500L)
i ++
logX("Second i = $i")
}
}
}
delay(2000)
parentJob.cancel()
parentJob.join()
logX("End")
}
/**
* * 打印[Job]的状态信息,这里使用```来控制文本格式不会变化
* */
fun Job.log() {
logX("""
isActive = $isActive
isCancelled = $isCancelled
isCompleted = $isCompleted
""".trimIndent())
}
/**
* 控制台输出带协程信息的log,该方法得打印,在打印[Thread]的name
* 时会携带协程信息
*
* 想实现这种效果,需要配合IDE的支持,操作如下:
* * 点击Make Project小锤子标记右边的项目选择框。
* * 选中 Edit Configurations
* * 在VM options中填入 -Dkotlinx.coroutines.debug
* */
fun logX(any: Any?) {
println("""
================================
$any
Thread:${Thread.currentThread().name}
================================
""".trimIndent())
}
上面主要看中间的代码,下面打印协程信息的方法我们也需要掌握,在main
代码中,我们的parentJob
中启动了2个子协程,但是其中有一个子协程的父协程,却不是parentJob
,这也就导致调用parentJob.cancel
时,该方法无法响应。
上面3个协程的关系如下所示:
这个例子也就告诉我们,在日常写代码时,不要随便打破这种父子协程的结构化关系。
CancellationException
异常需要特殊处理
我们继续说协程的取消,在前面我们说使用delay
挂起函数,可以让协程内部响应外部的cancel
方法,那这个是如何做到呢?
我们简单看一下delay
函数的注释后就知道:该挂起函数可以被取消的(cancelable
),在挂起函数还在等待时,当前的协程被取消了或者结束了,函数会立即恢复,并且携带一个CancellationException
异常。
这里就很有意思了,既然是异常,为什么代码没有做显性的处理,并且也不会崩溃,这里涉及到协程框架的特殊处理。我们先来捕获和验证一下该异常,已经它对协程的取消有什么作用:
代码如下:
fun main() = runBlocking {
val job = launch(Dispatchers.Default) {
var i = 0
while (true){
try {
delay(500)
}catch (e: CancellationException){
//捕获到异常,且再抛出去
logX("catch CancellationException")
throw e
}
i ++
println("i = $i")
}
}
delay(2000)
//取消job
job.cancel()
//等待job执行完成
job.join()
println("End")
}
上面代码加上try-catch
,来捕获CancellationException
,运行结果如下:
i = 1
i = 2
i = 3
i = 4
================================
catch CancellationException
Thread:DefaultDispatcher-worker-1 @coroutine#2
================================
End
会发现符合结果预期,这里当协程被取消时,会抛出一个Cancellation
异常,并且协程被成功取消。
可以发现delay
函数确实会在协程被取消时抛出一个CancellationException
异常,并且我们通过throw
抛给了协程,这时我们可以思考一下,如果把抛出动作给注释掉:
//捕获到异常,不抛出去
logX("catch CancellationException")
//throw e
上述代码情况下,协程虽然可以捕获到异常,但是协程不会退出。
这里我们也就可以猜出端倪了,之所以delay
函数可以让协程取消,全都因为它会抛出CancellationException
异常,协程框架通过该异常来取消协程。
这个例子也就告诉我们,在协程代码中,我们使用try-catch
时,对于异常的处理要细化,比如上面代码我们经常会这么写:
try {
delay(500)
//业务代码
}catch (e: Exception){
//捕获到业务异常,进行处理
logX("catch $e")
}
现在就不行了,通过上面分析,我们知道CancellationException
是特殊的异常,需要特殊处理,这是需要我们谨记的:当程序抓取到CancellationException
,需要考虑是否需要抛出。
其他异常处理手段
除了这个特殊的CancellationException
外,我们接着来如何处理其他普通的异常。
try-catch
不要直接包裹协程
首先就是我们使用try-catch
,在前面文章我们在介绍Flow
时说了Flow
有2种方法处理Flow
当中的异常,以及在介绍CoroutineContext
时提及了一下CoroutineExcetptionHandler
这个东西,我们先来看使用try-catch
有什么注意点。
话不多说,直接看下面代码:
fun main() = runBlocking {
try {
launch {
delay(100L)
1 / 0 // 故意制造异常
}
} catch (e: ArithmeticException) {
println("Catch: $e")
}
delay(500L)
println("End")
}
这里的代码运行的话会直接报错崩溃,会发现这个try-catch
并没有起作用,所以这里有个准则:千万不要用try-catch
直接包裹launch
、async
。而这里正确的用法是把try-catch
移到launch
的代码块内部使用即可。
至于这里为什么会出现这种情况,我们想一下前面所说的launch
启动协程就像射箭,而包裹在launch
外的try-catch
只能捕获launch
这个方法的异常,而对于其中的代码块的执行已经跳出了try-catch
的作用域了。
SupervisorJob
控制异常蔓延
在前面说协程结构化的时候,我们说父协程的取消会导致子协程的退出和取消,同理当子协程出现异常时,它也会取消父协程,以及取消其他子协程。这种机制,就有点好心办坏事的情况发生。
我们以捕获和处理async
协程为例,有人说async
启动的协程,我们只需要在其await
方法捕获异常即可,我们测试下面代码:
/**
* 对于[async]方法的异常处理
* */
fun main() = runBlocking {
val deferred = async {
delay(1000L)
1 / 0
}
//捕获异常
try {
deferred.await()
}catch (e: ArithmeticException){
println("Catch $e")
}
delay(5000L)
println("End")
}
/**
* 运行结果:
* Catch java.lang.ArithmeticException: / by zero
* 崩溃:Exception in thread "main" java.lang.ArithmeticException: / by zero
* */
在这里我们在协程中,会执行一个抛出异常的代码,虽然可以捕获到异常,但是程序依旧会崩溃。那我们不调用deferred.await
函数,可以控制崩溃吗:
fun main() = runBlocking {
val deferred = async {
delay(1000L)
1 / 0
}
//捕获异常
// try {
// deferred.await()
// }catch (e: ArithmeticException){
// println("Catch $e")
// }
delay(5000L)
println("End")
}
/**
* 运行结果:
* 崩溃:Exception in thread "main" java.lang.ArithmeticException: / by zero
* */
可以发现依旧不可以,虽然async
启动的协程是子协程,但是它里面发生了异常,依旧会导致整个程序的崩溃,这也是符合我们编程思维的。
那有没有什么办法呢?毕竟我不想其中一个协程有问题,就导致整个程序退出,协程中提供了一个叫做SupervisorJob
的特殊Job
,可以解决上面问题,我们来看一下如何解决:
/**
* 这里使用一个特殊的[SupervisorJob],来构建
* [CoroutineScope]
* */
fun main() = runBlocking {
//创建一个scope
val scope = CoroutineScope(SupervisorJob())
val deferred = scope.async {
delay(1000L)
1 / 0
}
//捕获异常
// try {
// deferred.await()
// }catch (e: ArithmeticException){
// println("Catch $e")
// }
delay(5000L)
println("End")
}
/**
* 运行结果:
* 正常结束,不会崩溃
* */
这里不会发生崩溃,程序可以正常执行和结束,我们甚至可以把上面捕获异常的代码给放开:
/**
* 这里使用一个特殊的[SupervisorJob],来构建
* [CoroutineScope]
* */
fun main() = runBlocking {
//创建一个scope
val scope = CoroutineScope(SupervisorJob())
val deferred = scope.async {
delay(1000L)
1 / 0
}
//捕获异常
try {
deferred.await()
}catch (e: ArithmeticException){
println("Catch $e")
}
delay(5000L)
println("End")
}
/**
* 运行结果:
* Catch java.lang.ArithmeticException: / by zero
* End
* */
在这种情况下,程序不仅没有崩溃,还捕获到了异常信息,就非常不错。
那这个SupervisorJob
是什么呢?我们点开源码定义:
public fun SupervisorJob(parent: Job? = null) : CompletableJob = SupervisorJobImpl(parent)
会发现这不是一个构造函数,而是一个顶层函数,返回一个特殊的Job
,这个Job
有什么特殊能力呢?
SupervisiorJob
的翻译可以直接翻译为超级管理员Job
,或者管理者Job
,其特殊之处就是:其子Job
其中的一个发生了异常之后,不会导致其他子Job
也异常和父Job
异常。
我们来看个动图,下面是普通的父Job
和子Job
,当子Job
出现异常时,所发生的牵连情况:
这里当job1
发生异常时,会导致父job
被取消,从而导致其他子job
受到牵连。
而SupervisiorJob
和其子Job
的情况如下:
当job1
发生异常时,不会影响其他Job
的正常执行。
所以这也就解释了为什么上面代码使用SupervisiorJob
创建的scope
,然后用该scope
启动的协程中发生了异常,不会导致整个程序退出,因为SupervisiorJob
把异常给控制住了。
这里也就可以得出一条结论:可以灵活使用SupervisiorJob
,控制异常传播的范围。
CoroutineExceptionHandler
在顶层协程兜底
这里讨论的协程层级还不够复杂,在实际业务中,我们经常会出现协程中启动协程,而且层级很深,我们无法保证在每个启动的协程中都有try-catch
语句,所以复杂的情况我们可以使用CoroutineExceptionHandler
。
在前面文章我们介绍过,这个其实是CoroutineContext
的元素之一,如其名就是异常处理器,简单使用如下:
/**
* 利用[CoroutineExceptionHandler]创建异常处理器,lambda中
* 就是发生异常的[CoroutineContext]和[Throwable]
* */
val myExceptionHandler = CoroutineExceptionHandler{_, throwable ->
println("Catch exception: $throwable")
}
fun main() = runBlocking{
//操作符重载操作,支持这样
val scope = CoroutineScope(coroutineContext + Job() + myExceptionHandler)
//复杂的协程嵌套
scope.launch {
async {
delay(1000)
}
launch {
delay(2000)
launch {
delay(1000)
//制作异常
1 / 0
}
}
}
delay(10000)
println("End")
/**
* 运行结果:
* Catch exception: java.lang.ArithmeticException: / by zero
* End
* */
}
在这里,我们仅仅使用CoroutineExceptionHandler
在顶层协程中就可以捕获其子协程中的异常,非常方便。
但是这里使用却有一个坑,我们看下面代码:
fun main() = runBlocking{
//操作符重载操作,支持这样
val scope = CoroutineScope(coroutineContext)
//复杂的协程嵌套
scope.launch {
async {
delay(1000)
}
launch {
delay(2000)
//仅对该协程进行设置Handler
launch(myExceptionHandler) {
delay(1000)
//制作异常
1 / 0
}
}
}
delay(10000)
println("End")
/**
* 运行结果:
* 崩溃: Exception in thread "main" java.lang.ArithmeticException: / by zero
* */
}
这里我们不在最顶层的协程使用CoroutineExceptionHandler
,而是更精确地在发生异常地协程中使用,却发现程序崩溃了。
所以,使用CoroutineExceptionHandler
处理复杂结构的协程异常,必须要放在顶层协程中。
在这里虽然我们可以使用CoroutineExceptionHandler
在顶层协程中捕获所有的异常,但是我们还是要根据实际情况,必要的try-catch
是不可少的。
因为一股脑的把所以异常都抛给这一个总异常处理器来处理,是无法解决具体逻辑业务代码的Bug的,毕竟在业务代码细节中,我们会对不同异常做处理,然后额外操作和处理。这个顶层协程的CoroutineExceptionHandler
更像是一个防止崩溃的兜底策略,类似Java中的UnCauthedExceptionHandler
。
总结
本篇关于协程的异常处理内容有点多,我们来做个总结:
- 协程的取消需要内部配合,这里要不在
while
中使用isActive
作为判断条件判断协程是否活跃,要不使用协程提供的挂起函数自动判断当前协程是否被取消。 - 不要轻易打破协程的父子结构,这里由于协程的结构化并发,很多特性都是基于这个的,所以在项目中,启动协程不要传入特殊的
Job
,即不要打破原本的父子结构。 - 当捕获
了CancellationException
时,要考虑是否要重新抛出。因为协程的结构化取消是依赖这个CancellationException
的,最好不要捕获这个异常,即使捕获了,一般也不需要处理。 - 不要使用
try-catch
来包裹launch
和async
。 - 灵活使用
SupervisiorJob
,可以控制异常传播的范围。比如Android中的ViewModelScope
就使用了SupervisiorJob
,即使该Job
的子Job
发生异常,也不会导致应用的其他功能出现问题。 - 可以使用
CoroutineExceptionHandler
在顶层协程中处理复杂结构的协程异常,相当于一个兜底策略,业务细节的代码异常处理还得用try-catch
。