28 KiB
23 | 异常:try-catch居然会不起作用?坑!
你好,我是朱涛。这节课我们来学习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大准则,这些我们都要牢记在心。
- 第一条准则:协程的取消需要内部的配合。
- 第二条准则:不要轻易打破协程的父子结构!这一点,其实不仅仅只是针对协程的取消异常,而是要贯穿于整个协程的使用过程中。我们知道,协程的优势在于结构化并发,它的许多特性都是建立在这个特性之上的,如果我们无意中打破了它的父子结构,就会导致协程无法按照预期执行。
- 第三条准则:捕获了CancellationException以后,要考虑是否应该重新抛出来。在协程体内部,协程是依赖于CancellationException来实现结构化取消的,有的时候我们出于某些目的需要捕获CancellationException,但捕获完以后,我们还需要思考是否需要将其重新抛出来。
- 第四条准则:不要用try-catch直接包裹launch、async。这一点是很多初学者会犯的错误,考虑到协程代码的执行顺序与普通程序不一样,我们直接使用try-catch包裹launch、async,是不会有任何效果的。
- 第五条准则:灵活使用SupervisorJob,控制异常传播的范围。SupervisorJob是一种特殊的Job,它可以控制异常的传播范围。普通的Job,它会因为子协程当中的异常而取消自身,而SupervisorJob则不会受到子协程异常的影响。在很多业务场景下,我们都不希望子协程影响到父协程,所以SupervisorJob的应用范围也非常广。比如说Android当中的viewModelScope,它就使用了SupervisorJob,这样一来,我们的App就不会因为某个子协程的异常导致整个应用的功能出现紊乱。
- 第六条准则:使用CoroutineExceptionHandler处理复杂结构的协程异常,它仅在顶层协程中起作用。我们都知道,传统的try-catch在协程当中并不能解决所有问题,尤其是在协程嵌套层级较深的情况下。这时候,Kotlin官方为我们提供了CoroutineExceptionHandler作为补充。有了它,我们可以轻松捕获整个作用域内的所有异常。
其实,这节课里我提到的这些案例,只是我平时工作中遇到的很小一部分。案例是讲不完的,在协程中处理异常,你将来肯定也会遇到千奇百怪的问题。但重要的是分析问题的思路,还有解决问题的手段。这节课我给你总结的6大准则呢,就是你将来遇到协程异常时,可以用的6种处理手段。
当我们遇到问题的时候,首先要分析是CancellationException导致的,还是其他异常导致的。接着我们就可以根据实际情况去思考,该用哪种处理手段了。
另外如果你足够细心的话,你会发现这节课总结出的6大准则,其实都跟协程的结构化并发有着密切联系。由于协程之间存在父子关系,因此它的异常处理也是遵循这一规律的。而协程的异常处理机制之所以这么复杂,也是因为它的结构化并发特性。
所以,除了这6大准则以外,我们还可以总结出一个核心理念:因为协程是“结构化的”,所以异常传播也是“结构化的”。
如果你能理解协程异常处理的核心理念,同时能够牢记前面的6大准则。我相信,将来不论你遇到什么样的古怪问题,你都可以分析出问题的根源,找到解决方案!
思考题
前面我们提到过,CoroutineExceptionHandler可以一次性捕获整个作用域内所有协程的异常。那么,我们是不是可以抛弃try-catch,只使用CoroutineExceptionHandler呢?为什么?
欢迎在留言区分享你的答案,也欢迎你把今天的内容分享给更多的朋友。