你好,我是朱涛。这节课我们来学习Kotlin协程的异常处理。

其实到这里,我们就已经学完所有Kotlin协程的语法知识了。但在真正把Kotlin协程应用到生产环境之前,我们还需要掌握一个重要知识点,那就是异常处理。

比起Kotlin协程的语法知识点,协程的异常处理,其实更难掌握。在前面的课程中,我们已经了解到:协程就是互相协作的程序,协程是结构化的。正因为Kotlin协程有这两个特点,这就导致它的异常处理机制与我们普通的程序完全不一样。

换句话说:如果把Java里的那一套异常处理机制,照搬到Kotlin协程里来,你一定会四处碰壁。因为在普通的程序当中,你使用try-catch就能解决大部分的异常处理问题,但是在协程当中,根据不同的协程特性,它的异常处理策略是随之变化的。

我自己在工作中就踩过很多这方面的坑,遇到过各种匪夷所思的问题:协程无法取消、try-catch不起作用导致线上崩溃率突然大增、软件功能错乱却追踪不到任何异常信息,等等。说实话,Kotlin协程的普及率之所以不高,很大一部分原因也是因为它的异常处理机制太复杂了,稍有不慎就可能会掉坑里去。

那么今天这节课,我们就会来分析几个常见的协程代码模式,通过解决这些异常,我们可以总结出协程异常处理的6大准则。掌握了这些准则之后,你在以后遇到异常问题时,就能有所准备,也知道该怎么处理了。

为什么cancel()不起作用?

在Kotlin协程当中,我们通常把异常分为两大类,一类是取消异常(CancellationException),另一类是其他异常。之所以要这么分类,是因为在Kotlin协程当中,这两种异常的处理方式是不一样的。或者说,在Kotlin协程所有的异常当中,我们需要把CancellationException单独拎出来,特殊对待。

要知道,当协程任务被取消的时候,它的内部是会产生一个CancellationException的。而协程的结构化并发,最大的优势就在于:如果我们取消了父协程,子协程也会跟着被取消。但是我们也知道,很多初学者都会遇到一个问题,那就是协程无法被取消。

这里,主要涉及了三个场景,我们一个个来分析下。

场景1:cancel()不被响应

// 代码段1

fun main() = runBlocking {
    val job = launch(Dispatchers.Default) {
        var i = 0
        while (true) {
            Thread.sleep(500L)
            i ++
            println("i = $i")
        }
    }

    delay(2000L)

    job.cancel()
    job.join()

    println("End")
}

/*
输出结果

i = 1
i = 2
i = 3
i = 4
i = 5
// 永远停不下来
*/

在上面的代码中,我们启动了一个协程,在这个协程的内部,我们一直对i进行自增。过了2000毫秒以后,我们调用了job.cancel()。但通过运行的结果,我们可以看到协程并不会被取消。这是为什么呢?

其实前面课程里我们就讲过,协程是互相协作的程序。因此,对于协程任务的取消,也是需要互相协作的。协程外部取消,协程内部需要做出响应才行。具体来说,我们可以在协程体中加入状态判断:

// 代码段2

fun main() = runBlocking {
    val job = launch(Dispatchers.Default) {
        var i = 0
        // 变化在这里
        while (isActive) {
            Thread.sleep(500L)
            i ++
            println("i = $i")
        }
    }

    delay(2000L)

    job.cancel()
    job.join()

    println("End")
}

/*
输出结果
i = 1
i = 2
i = 3
i = 4
i = 5
End
*/

在这段代码里,我们把while循环的条件改成了while (isActive),这就意味着,只有协程处于活跃状态的时候,才会继续执行循环体内部的代码。

这里,我们就可以进一步分析代码段1无法取消的原因了:当我们调用job.cancel()以后,协程任务已经不是活跃状态了,但代码并没有把isActive作为循环条件,因此协程无法真正取消。

所以到这里,我们就可以总结出协程异常处理的第一准则了:协程的取消需要内部的配合

场景2:结构被破坏

我们都知道,协程是结构化的,当我们取消父协程的时候,子协程也会跟着被取消。比如,我们在第16讲当中,就看到过这张图:

图片

但在某些情况下,我们嵌套创建的子协程并不会跟随父协程一起取消,比如下面这个案例:

// 代码段3

val fixedDispatcher = Executors.newFixedThreadPool(2) {
    Thread(it, "MyFixedThread").apply { isDaemon = false }
}.asCoroutineDispatcher()

fun main() = runBlocking {
    // 父协程
    val parentJob = launch(fixedDispatcher) {

        // 1,注意这里
        launch(Job()) { // 子协程1
            var i = 0
            while (isActive) {
                Thread.sleep(500L)
                i ++
                println("First i = $i")
            }
        }

        launch { // 子协程2
            var i = 0
            while (isActive) {
                Thread.sleep(500L)
                i ++
                println("Second i = $i")
            }
        }
    }

    delay(2000L)

    parentJob.cancel()
    parentJob.join()

    println("End")
}

/*
输出结果
First i = 1
Second i = 1
First i = 2
Second i = 2
Second i = 3
First i = 3
First i = 4
Second i = 4
End
First i = 5
First i = 6
// 子协程1永远不会停下来
*/

以上代码中,我们创建了一个fixedDispatcher,它是由两个线程的线程池实现的。接着,我们通过launch创建了三个协程,其中parentJob是父协程,随后我们等待2000毫秒,然后取消父协程。

不过,通过程序的运行结果,我们发现,虽然“子协程1”当中使用了while(isActive)作为判断条件,它也仍然无法被取消。其实,这里的主要原因还是在注释1处,我们在创建子协程的时候,使用了launch(Job()){}。而这种创建方式,就打破了原有的协程结构。

为了方便你理解,我画了一张图,描述它们之间的父子关系。

根据这张图,可以看到“子协程1”已经不是parentJob的子协程了,而对应的,它的父Job是我们在launch当中传入的Job()对象。所以,在这种情况下,当我们调用parentJob.cancel()的时候,自然也就无法取消“子协程1”了。

其实这个时候,如果我们稍微改动一下上面的代码,不传入Job(),程序就可以正常运行了。

// 代码段4

fun main() = runBlocking {
    val parentJob = launch(fixedDispatcher) {

        // 变化在这里
        launch {
            var i = 0
            while (isActive) {
                Thread.sleep(500L)
                i ++
                println("First i = $i")
            }
        }

        launch {
            var i = 0
            while (isActive) {
                Thread.sleep(500L)
                i ++
                println("Second i = $i")
            }
        }
    }

    delay(2000L)

    parentJob.cancel()
    parentJob.join()

    println("End")
}

/*
输出结果
First i = 1
Second i = 1
First i = 2
Second i = 2
First i = 3
Second i = 3
First i = 4
Second i = 4
End
*/

在上面的代码中,parentJob与它内部的子协程1、子协程2之间是父子关系,因此它们两个都是会响应协程取消的事件的。这时候,它们之间的关系就变成了下图这样:

那么到这里,我们其实就可以总结出第二条准则了:不要轻易打破协程的父子结构

场景3:未正确处理CancellationException

其实,对于Kotlin提供的挂起函数,它们是可以自动响应协程的取消的,比如说,当我们把Thread.sleep(500)改为delay(500)以后,我们就不需要在while循环当中判断isActive了。

// 代码段5

fun main() = runBlocking {

    val parentJob = launch(Dispatchers.Default) {
        launch {
            var i = 0
            while (true) {
                // 变化在这里
                delay(500L)
                i ++
                println("First i = $i")
            }
        }

        launch {
            var i = 0
            while (true) {
                // 变化在这里
                delay(500L)
                i ++
                println("Second i = $i")
            }
        }
    }

    delay(2000L)

    parentJob.cancel()
    parentJob.join()

    println("End")
}

/*
输出结果
First i = 1
Second i = 1
First i = 2
Second i = 2
First i = 3
Second i = 3
End
*/

实际上,对于delay()函数来说,它可以自动检测当前的协程是否已经被取消,如果已经被取消的话,它会抛出一个CancellationException,从而终止当前的协程。

为了证明这一点,我们可以在以上代码的基础上,增加一个try-catch。

// 代码段6

fun main() = runBlocking {

    val parentJob = launch(Dispatchers.Default) {
        launch {
            var i = 0
            while (true) {
                // 1
                try {
                    delay(500L)
                } catch (e: CancellationException) {
                    println("Catch CancellationException")
                    // 2
                    throw e
                }
                i ++
                println("First i = $i")
            }
        }

        launch {
            var i = 0
            while (true) {
                delay(500L)
                i ++
                println("Second i = $i")
            }
        }
    }

    delay(2000L)

    parentJob.cancel()
    parentJob.join()

    println("End")
}

/*
输出结果
First i = 1
Second i = 1
First i = 2
Second i = 2
First i = 3
Second i = 3
Second i = 4
Catch CancellationException
End
*/

请看注释1,在用try-catch包裹了delay()以后,我们就可以在输出结果中,看到“Catch CancellationException”,这就说明delay()确实可以自动响应协程的取消,并且产生CancellationException异常。

不过,以上代码中,最重要的其实是注释2:“throw e”。当我们捕获到CancellationException以后,还要把它重新抛出去。而如果我们删去这行代码的话,子协程将同样无法被取消。

// 代码段7

fun main() = runBlocking {

    val parentJob = launch(Dispatchers.Default) {
        launch {
            var i = 0
            while (true) {
                try {
                    delay(500L)
                } catch (e: CancellationException) {
                    println("Catch CancellationException")
                    // 1,注意这里
                    // throw e
                }
                i ++
                println("First i = $i")
            }
        }

        launch {
            var i = 0
            while (true) {
                delay(500L)
                i ++
                println("Second i = $i")
            }
        }
    }

    delay(2000L)

    parentJob.cancel()
    parentJob.join()

    println("End")
}

/*
输出结果
输出结果
First i = 1
Second i = 1
First i = 2
Second i = 2
First i = 3
Second i = 3
Second i = 4
..
First i = 342825
Catch CancellationException
// 程序将永远无法终止
*/

可见,在这段代码中,我们把“throw e”这行代码注释掉,重新运行之后,程序就永远无法终止了。这主要是因为,我们捕获了CancellationException以后没有重新抛出去,就导致子协程无法正常取消。

所以到这里,我们就可以总结出第三条准则了:捕获了CancellationException以后,要考虑是否应该重新抛出来

题外话:很多开发者喜欢在代码里捕获Exception这个父类,比如这样:catch(e: Exception){},这也是很危险的。平时写Demo为了方便这样写没问题,但在生产环境则应该禁止。

好,到这里,我们就通过协程取消异常的三个场景,总结了三条准则,来应对CancellationException这个特殊的异常。

那么接下来,我们再来看看如何在协程当中处理普通的异常。

为什么try-catch不起作用?

如果你有Java经验,那你一定会习惯性地把try-catch当做是解决所有异常的手段。但是,在Kotlin协程当中,try-catch并非万能的。有时候,即使你用try-catch包裹了可能抛异常的代码,软件仍然会崩溃。比如下面这个例子:

// 代码段8

fun main() = runBlocking {
    try {
        launch {
            delay(100L)
            1 / 0 // 故意制造异常
        }
    } catch (e: ArithmeticException) {
        println("Catch: $e")
    }

    delay(500L)
    println("End")
}

/*
输出结果:
崩溃
Exception in thread "main" ArithmeticException: / by zero
*/

在这段代码中,我们使用try-catch包裹了launch{},在协程体内部,我们制造了一个异常。不过从运行结果这里,我们可以看到,try-catch并没有成功捕获异常,程序等待了100毫秒左右,最终还是崩溃了。

类似的,如果我们把代码段8当中的launch换成async,结果也是差不多的:

// 代码段9

fun main() = runBlocking {
    var deferred: Deferred<Unit>? = null
    try {
        deferred = async {
            delay(100L)
            1 / 0
        }
    } catch (e: ArithmeticException) {
        println("Catch: $e")
    }

    deferred?.await()

    delay(500L)
    println("End")
}

/*
输出结果:
崩溃
Exception in thread "main" ArithmeticException: / by zero
*/

其实,对于这种try-catch失效的问题,如果你还记得在第14讲当中,我提到的launch、async的代码运行顺序的问题,那你就一定可以理解其中的原因。这主要就是因为,当协程体当中的“1/0”执行的时候,我们的程序已经跳出try-catch的作用域了。

当然,要解决这两个问题也很容易。对于代码段8来说,我们可以挪动一下try-catch的位置,比如说这样:

// 代码段10

fun main() = runBlocking {

    launch {
        try {
            delay(100L)
            1 / 0 // 故意制造异常
        } catch (e: ArithmeticException) {
            println("Catch: $e")
        }
    }

    delay(500L)
    println("End")
}

/*
输出结果:
Catch: java.lang.ArithmeticException: / by zero
End
*/

也就是说,我们可以把try-catch挪到launch{} 协程体内部。这样一来,它就可以正常捕获到ArithmeticException这个异常了。

而对于代码段9的async的这个例子,我们其实有两种解决手段,其中一种跟上面的做法一样,我们把try-catch挪到了async{} 协程体内部,比如这样:

// 代码段11

fun main() = runBlocking {
    var deferred: Deferred<Unit>? = null

    deferred = async {
        try {
            delay(100L)
            1 / 0
        } catch (e: ArithmeticException) {
            println("Catch: $e")
        }
    }

    deferred?.await()

    delay(500L)
    println("End")
}

OK,到这里,我们就可以总结出第四条准则了:不要用try-catch直接包裹launch、async

接下来,我们再看看async的另外一种手段,其实这种方式网上有些博客也介绍过,我们可以使用try-catch包裹“deferred.await()”。让我们来看看是否可行:

// 代码段12

fun main() = runBlocking {
    val deferred = async {
        delay(100L)
        1 / 0
    }

    try {
        deferred.await()
    } catch (e: ArithmeticException) {
        println("Catch: $e")
    }

    delay(500L)
    println("End")
}

/*
输出结果
Catch: java.lang.ArithmeticException: / by zero
崩溃:
Exception in thread "main" ArithmeticException: / by zero
*/

那么,根据以上程序的运行结果可以看到,这样做其实是行不通的。如果你看过一些其他博客,甚至还有种说法是:await()如果不调用的话,async当中的异常甚至不会发生。我们再来试试看:

// 代码段13

fun main() = runBlocking {
    val deferred = async {
        delay(100L)
        1 / 0
    }

    delay(500L)
    println("End")
}

/*
输出结果
崩溃:
Exception in thread "main" ArithmeticException: / by zero
*/

可见,async当中产生异常,即使我们不调用await()同样是会导致程序崩溃的。那么,为什么会发生这样的情况?是不是我们忽略了什么?

SupervisorJob

实际上,如果我们要使用try-catch包裹“deferred.await()”的话,还需要配合SupervisorJob一起使用。也就是说,借助SupervisorJob来改造代码段13的话,我们就可以实现“不调用await()就不会产生异常而崩溃”。

// 代码段14

fun main() = runBlocking {
    val scope = CoroutineScope(SupervisorJob())
    scope.async {
        delay(100L)
        1 / 0
    }

    delay(500L)
    println("End")
}

/*
输出结果
End
*/

可以看到,当我们使用SupervisorJob创建一个scope以后,用scope.async{}启动协程后,只要不调用“deferred.await()”,程序就不会因为异常而崩溃。

所以同样的,我们也能用类似的办法来改造代码段12当中的逻辑:

// 代码段15

fun main() = runBlocking {
    val scope = CoroutineScope(SupervisorJob())
    // 变化在这里
    val deferred = scope.async {
        delay(100L)
        1 / 0
    }

    try {
        deferred.await()
    } catch (e: ArithmeticException) {
        println("Catch: $e")
    }

    delay(500L)
    println("End")
}

/*
输出结果
Catch: java.lang.ArithmeticException: / by zero
End
*/

在上面的代码中,我们仍然使用“scope.async {}”创建了协程,同时也用try-catch包裹“deferred.await()”,这样一来,这个异常就成功地被我们捕获了。

那么,SupervisorJob到底是何方神圣?让我们来看看它的源码定义:

// 代码段16

public fun SupervisorJob(parent: Job? = null) : CompletableJob 
                    = SupervisorJobImpl(parent)



public interface CompletableJob : Job {
    public fun complete(): Boolean

    public fun completeExceptionally(exception: Throwable): Boolean
}

根据以上代码,我们可以看到,SupervisorJob()其实不是构造函数,它只是一个普通的顶层函数。而这个方法返回的对象,是Job的子类。

SupervisorJob与Job最大的区别就在于,当它的子Job发生异常的时候,其他的子Job不会受到牵连。我这么说你可能会有点懵,下面我做了一个动图,来演示普通Job与SupervisorJob之间的差异。

图片

这个是普通Job,对于子Job出现异常时的应对策略。可以看到,由于parentJob是一个普通的Job对象,当job1发生异常之后,它会导致parentJob取消,进而导致job2、job3也受到牵连。

而这时候,如果我们把parentJob改为SupervisorJob,job1发生异常的的话,就不会影响到其他的Job了。

所以到这里,我们就可以总结出第五条准则了:灵活使用SupervisorJob,控制异常传播的范围

提示:并非所有情况下,我们都应该使用SupervisorJob,有时候Job会更合适,这要结合实际场景分析。

好,到目前为止,我们就已经了解了try-catch和SupervisorJob这两种处理异常的手段。但是,由于协程是结构化的,当我们的协程任务出现复杂的层级时,这两种手段其实都无法很好的应对。所以这个时候,我们就需要CoroutineExceptionHandler出场了。

CoroutineExceptionHandler

对于CoroutineExceptionHandler,我们其实在第17讲里也简单地提到过。它是CoroutineContext的元素之一,我们在创建协程的时候,可以指定对应的CoroutineExceptionHandler。

那么CoroutineExceptionHandler究竟适用于什么样的场景呢?让我们来看一个例子:

// 代码段17

fun main() = runBlocking {

    val scope = CoroutineScope(coroutineContext)

    scope.launch {
        async {
            delay(100L)
        }

        launch {
            delay(100L)

            launch {
                delay(100L)
                1 / 0 // 故意制造异常
            }
        }

        delay(100L)
    }

    delay(1000L)
    println("End")
}

/*
输出结果
Exception in thread "main" ArithmeticException: / by zero
*/

在上面的代码中,我模拟了一个复杂的协程嵌套场景。对于这样的情况,我们其实很难一个个在每个协程体里面去写try-catch。所以这时候,为了捕获到异常,我们就可以使用CoroutineExceptionHandler了。

// 代码段18

fun main() = runBlocking {
    val myExceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("Catch exception: $throwable")
    }

    // 注意这里
    val scope = CoroutineScope(coroutineContext + Job() + myExceptionHandler)

    scope.launch {
        async {
            delay(100L)
        }

        launch {
            delay(100L)

            launch {
                delay(100L)
                1 / 0 // 故意制造异常
            }
        }

        delay(100L)
    }

    delay(1000L)
    println("End")
}

/*
Catch exception: ArithmeticException: / by zero
End
*/

以上代码中,我们定义了一个CoroutineExceptionHandler,然后把它传入了scope当中,这样一来,我们就可以捕获其中所有的异常了。

看到这里,你也许松了一口气:终于有了一个简单处理协程异常的方式了。不过,你也别高兴得太早,因为我曾经就踩过CoroutineExceptionHandler的一个坑,最终导致App功能大面积异常。

而出现这个问题的原因就是:CoroutineExceptionHandler不起作用了!

为什么CoroutineExceptionHandler不起作用?

为了模拟我当时的业务场景,我把代码段18稍作改动。

// 代码段19
fun main() = runBlocking {
   val myExceptionHandler = CoroutineExceptionHandler { _, throwable ->
       println("Catch exception: $throwable")
   }

   // 不再传入myExceptionHandler
   val scope = CoroutineScope(coroutineContext)
   scope.launch {
       async {
           delay(100L)
       }
       launch {
           delay(100L)
           // 变化在这里
           launch(myExceptionHandler) {
               delay(100L)
               1 / 0 
           }
       }
       delay(100L)
   }
   delay(1000L)
   println("End")
}
/*
输出结果
崩溃:
Exception in thread "main" ArithmeticException: / by zero
*/

请你留意上面的注释,我们把自定义的myExceptionHandler,放到出现异常的launch那里传了进去。按理说,程序的执行结果是不会发生变化才对的。但实际上,myExceptionHandler并不会起作用,我们的异常不会被它捕获。

如果你对比代码段18和代码段19,你会发现,myExceptionHandler直接定义在发生异常的位置反而不生效,而定义在最顶层却可以生效!你说它的作用域是不是很古怪?

图片

其实,出现这种现象的原因,就是因为:CoroutineExceptionHandler只在顶层的协程当中才会起作用。也就是说,当子协程当中出现异常以后,它们都会统一上报给顶层的父协程,然后顶层的父协程才会去调用CoroutineExceptionHandler,来处理对应的异常。

那么到这里,我们就可以总结出第六条准则了:使用CoroutineExceptionHandler处理复杂结构的协程异常,它仅在顶层协程中起作用

小结

至此,这节课的内容就接近尾声了,我们来做一个简单的总结。

在Kotlin协程当中,异常主要分为两大类,一类是协程取消异常(CancellationException),另一类是其他异常。为了处理这两大类问题,我们一共总结出了6大准则,这些我们都要牢记在心。

其实,这节课里我提到的这些案例,只是我平时工作中遇到的很小一部分。案例是讲不完的,在协程中处理异常,你将来肯定也会遇到千奇百怪的问题。但重要的是分析问题的思路,还有解决问题的手段。这节课我给你总结的6大准则呢,就是你将来遇到协程异常时,可以用的6种处理手段。

当我们遇到问题的时候,首先要分析是CancellationException导致的,还是其他异常导致的。接着我们就可以根据实际情况去思考,该用哪种处理手段了。

另外如果你足够细心的话,你会发现这节课总结出的6大准则,其实都跟协程的结构化并发有着密切联系。由于协程之间存在父子关系,因此它的异常处理也是遵循这一规律的。而协程的异常处理机制之所以这么复杂,也是因为它的结构化并发特性。

所以,除了这6大准则以外,我们还可以总结出一个核心理念:因为协程是“结构化的”,所以异常传播也是“结构化的”

如果你能理解协程异常处理的核心理念,同时能够牢记前面的6大准则。我相信,将来不论你遇到什么样的古怪问题,你都可以分析出问题的根源,找到解决方案!

思考题

前面我们提到过,CoroutineExceptionHandler可以一次性捕获整个作用域内所有协程的异常。那么,我们是不是可以抛弃try-catch,只使用CoroutineExceptionHandler呢?为什么?

欢迎在留言区分享你的答案,也欢迎你把今天的内容分享给更多的朋友。