协程(13) | 异常处理

3,047

前言

如果说协程的挂起函数、结构化并发、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个协程的关系如下所示:

image.png

这个例子也就告诉我们,在日常写代码时,不要随便打破这种父子协程的结构化关系

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直接包裹launchasync。而这里正确的用法是把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出现异常时,所发生的牵连情况:

rr.gif

这里当job1发生异常时,会导致父job被取消,从而导致其他子job受到牵连。

SupervisiorJob和其子Job的情况如下:

image.png

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

总结

本篇关于协程的异常处理内容有点多,我们来做个总结:

  1. 协程的取消需要内部配合,这里要不在while中使用isActive作为判断条件判断协程是否活跃,要不使用协程提供的挂起函数自动判断当前协程是否被取消。
  2. 不要轻易打破协程的父子结构,这里由于协程的结构化并发,很多特性都是基于这个的,所以在项目中,启动协程不要传入特殊的Job,即不要打破原本的父子结构。
  3. 当捕获了CancellationException时,要考虑是否要重新抛出。因为协程的结构化取消是依赖这个CancellationException的,最好不要捕获这个异常,即使捕获了,一般也不需要处理。
  4. 不要使用try-catch来包裹launchasync
  5. 灵活使用SupervisiorJob,可以控制异常传播的范围。比如Android中的ViewModelScope就使用了SupervisiorJob,即使该Job的子Job发生异常,也不会导致应用的其他功能出现问题。
  6. 可以使用CoroutineExceptionHandler在顶层协程中处理复杂结构的协程异常,相当于一个兜底策略,业务细节的代码异常处理还得用try-catch