End

Kotlin 朱涛-23 协程 异常 try-catch Exception

本文地址


目录

23 | 异常:try-catch 居然会不起作用?

协程就是互相协作的程序协程是结构化的。正因为协程这两个特点,导致它的异常处理机制与普通的程序完全不一样。

在普通的程序中,使用 try-catch 就能解决大部分的异常处理问题,但是在协程中,根据不同的协程特性,它的异常处理策略是随之变化的。

Kotlin 协程的普及率之所以不高,很大一部分原因也是因为,它的异常处理机制太复杂了,稍有不慎就可能会掉坑里去。

为什么协程无法被取消

Kotlin 协程中的异常分为两大类,一类是取消异常 CancellationException,另一类是其他异常。换句话说就是,取消异常需要特殊对待

当协程任务被取消的时候,它的内部是会产生一个 CancellationException 的。而协程的结构化并发,最大的优势就在于:如果我们取消了父协程,子协程也会跟着被取消。

协程的取消需要内部逻辑配合

fun main() = runBlocking {
    val job = launch(Dispatchers.Default) {
        var i = 0
        while (true) {         // 调用 cancel() 后,协程内部的循环任务并不会被取消
            Thread.sleep(500L) // 注意:如果使用的是 delay,则可以正常停止协程任务
            i++
            println("i = $i")
        }
    }
    job.invokeOnCompletion { println("over") } // 此方法不会回调,表明协程任务没有结束

    delay(2000L)
    job.cancel()          // 取消协程
    println(job.isActive) // false,此时协程已经不是活跃状态了
    job.join()            // Suspends the coroutine until this job is complete
    println("End")        // 由于 join() 会被挂起,且无法恢复,所以程序也永远停不下来
}

上面的代码中,调用 cancel() 后,协程任务并不会被取消。因为虽然此时协程已经不是活跃状态了,但协程内部的代码不会主动响应此状态,因此协程就无法真正取消。

需主动判断当前协程是否活跃

第一种解决方案是,把 while 循环的条件改成 while (isActive),即只有协程处于活跃状态的时候,才继续执行循环体内部的代码。

while (isActive) {     // 只有协程处于活跃状态的时候,才继续执行循环
    Thread.sleep(500L)
    i++
    println("i = $i")
}
i = 1
i = 2
i = 3
i = 4
false
i = 5
over
End

这种方案不推荐,因为响应不及时:只有在下次执行 while 时,才会判断协程是否活跃。

挂起函数可自动响应协程取消

第二种解决方案是,改为使用 Kotlin 的挂起函数,因为挂起函数可以自动响应协程的取消。所以,如果把 Thread.sleep(500) 改为 delay(500),就不需要在 while 循环中判断 isActive 了。

while (true) {
    delay(500) // 挂起函数 delay,可以自动响应协程的取消
    i++
    println("i = $i")
}
i = 1
i = 2
i = 3
false
over
End

处理好 CancellationException

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

while (true) {
    try {
        delay(500L)
    } catch (e: CancellationException) { // 捕获协程取消时的异常
        println("Catch CancellationException")
        throw e // 注意:需要重新把异常抛出去,否则协程将无法被取消
    }
    i++
    println("i = $i")
}
i = 1
i = 2
i = 3
false
Catch CancellationException
over
End

从输出结果中可以说明,delay() 确实可以自动响应协程的取消,并且产生 CancellationException 异常。

注意:上面的代码中,当我们捕获到 CancellationException 以后,又通过 throw e 把它重新抛了出去。而如果删去这行代码的话,协程将同样无法被取消,并且,程序也永远无法终止。

子协程会跟着父协程一并取消

协程是结构化的,当取消父协程的时候,子协程也会跟着被取消。

fun main() = runBlocking {
    val dispatcher = Executors.newFixedThreadPool(2) { Thread(it, "bqt") }.asCoroutineDispatcher()
    var cJob: Job? = null
    val pJob = launch(dispatcher) { // 注意:使用线程池后,程序无法结束,除非创建线程时定义为守护线程
        cJob = launch {
            var i = 0
            while (true) {
                delay(500)
                i++
                println("cJob - $i")
            }
        }
        cJob?.invokeOnCompletion { println("cJob invokeOnCompletion") }
    }
    pJob.invokeOnCompletion { println("pJob invokeOnCompletion") }

    delay(2000L)
    pJob.cancel()
    println("${pJob.isActive} - ${cJob?.isActive}") // false - true - false
    pJob.join()
    println("End")
}
cJob - 1
cJob - 2
cJob - 3
false - false
cJob invokeOnCompletion // 子协程首先结束了
pJob invokeOnCompletion // 父协程会在【所有子协程结束后】才结束
End

注意:使用线程池后,程序无法结束,除非创建线程时设为守护线程:Thread(it, "bqt").apply { isDaemon = true }
但是,如果定义为守护线程,当主线程结束后,所有的守护线程不管处于什么状态,都一并结束了,这不利于下面的分析

不要轻易打破协程的父子结构

但在某些情况下,如果打破协程的父子结构,子协程将不会再跟随父协程一起取消。

cJob = launch(Job()) { // 在创建子协程的时候,使用其他上下文,会打破原有的协程结构
    var i = 0
    while (isActive) { // 即使使用 isActive 作为判断条件,也仍然无法取消子协程
        delay(500)
        i++
        println("cJob - $i")
    }
}
pJob invokeOnCompletion // 父协程立即就结束了,而不会再等待"子协程"结束,所以父子关系已经被打破了
cJob - 1
cJob - 2
cJob - 3
false - true            // 同样,"子协程" 不会跟随父协程一起取消
End
cJob - 4
cJob - 5
// ...

可以看到,如果我们使用了 launch(Job()){} 这种方式创建子协程,就打破了原有的协程结构。因为此时 cJob 已经不是 pJob 的子协程了,它的父 Job 是我们在 launch 中传入的 Job() 对象。

为什么 try-catch 不起作用

在 Kotlin 协程中,try-catch 并非万能的,有时候,即使你用 try-catch 包裹了可能抛异常的代码,软件仍然会崩溃。

协程外部无法 catch 内部异常

fun main() = runBlocking {
    try { // 用 try-catch 直接包裹 launch、async,不能捕获协程内部的异常
        launch { 1 / 0 }
    } catch (e: ArithmeticException) {
        println("Catch: $e")
    }
    delay(500L)
    println("End")
}
Exception in thread "main" java.lang.ArithmeticException: / by zero
// ...

从运行结果可以看到,try-catch 并没有成功捕获异常,因为协程体中程序已经跳出 try-catch 的作用域了。这和 Java 中,线程外部的 try-catch 无法捕获线程内部的异常是一样的。

只需要把 try-catch 挪到 launch{} 协程体内部,就可以正常捕获到 ArithmeticException 了。

launch {
    try {
        1 / 0
    } catch (e: ArithmeticException) {
        println("Catch: $e")
    }
}
Catch: java.lang.ArithmeticException: / by zero
End

catch await 不能消化异常

如果使用 async 创建的协程内部会产生异常,正常情况下

  • 即使不调用 deferred.await(),也会导致程序异常崩溃
  • 使用 try-catch 包裹 await() 后,虽然可以捕获到异常,但不能消化掉异常,所以依然会导致程序异常崩溃
fun main() = runBlocking {
    val deferred = async { 1 / 0 }
    try {
        deferred.await() // 使用 try-catch 包裹 await()
    } catch (e: ArithmeticException) {
        println("Catch: $e")
    }
    delay(500L)
    println("End")
}
Catch: java.lang.ArithmeticException: / by zero
Exception in thread "main" java.lang.ArithmeticException: / by zero

从运行结果可以看到,虽然try-catch 捕获到了异常,但程序最终还是崩溃了。

使用 SupervisorJob 控制范围

使用 SupervisorJob,可以控制异常传播的范围。

不调用 await 不产生异常

借助 SupervisorJob,可以实现 不调用 await() 就不会产生异常

fun main() = runBlocking {
    val scope = CoroutineScope(SupervisorJob()) // 使用 SupervisorJob
    scope.async { 1 / 0 } // 因为没有调用 await(),所以不会产生异常
    delay(500L)
    println("End")        // 程序会正常打印 End,并且会正常结束
}

catch await 能消化异常

借助 SupervisorJob,也可以 捕获调用 await() 后产生的异常

fun main() = runBlocking {
    val scope = CoroutineScope(SupervisorJob())
    val deferred = scope.async { 1 / 0 }
    try {                // 使用 try-catch 包裹 await()
        deferred.await() // 可以捕获调用 await() 后产生的异常
    } catch (e: ArithmeticException) {
        println("Catch: $e")
    }
    delay(500L)
    println("End")
}
Catch: java.lang.ArithmeticException: / by zero
End

SupervisorJob 源码解析

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 的子类 CompletableJob。

SupervisorJob 与 Job 的区别:

  • 对于普通的 Job,当某一个子 Job 出现异常时,会导致 parentJob 取消,进而导致其他子 Job 也受到牵连
  • 对于 SupervisorJob,当某一个子 Job 出现异常时,parentJob、其他子 Job 都不会受到牵连

CoroutineExceptionHandler

协程是结构化的,当协程任务出现复杂的嵌套层级时,我们很难在每个协程体里面去写 try-catch,这时就要用到 CoroutineExceptionHandler 了。

fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, throwable -> println("Catch : $throwable") }
    val scope = CoroutineScope(coroutineContext + Job() + handler)
    scope.launch { launch { launch { 1 / 0 } } }
    delay(1000L)
    println("End")
}
Catch : java.lang.ArithmeticException: / by zero
End

CoroutineExceptionHandler 其实类似 Java 的 UncaughtExceptionHandler,只适合做兜底操作
Thread.setDefaultUncaughtExceptionHandler(handler);

Handler 仅在顶层协程起作用

fun main() = runBlocking {
    launch {
        launch {
            val handler = CoroutineExceptionHandler  { _, t -> println("Catch : $t") }
            launch(handler) { 1 / 0 } // Handler 不起作用,异常不会被捕获
        }
    }
    delay(1000L)
    println("End")
}
Exception in thread "main" java.lang.ArithmeticException: / by zero
// ...

以上代码中的 Handler 不会起作用,代码中的异常也不会被它捕获。这是因为: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 在协程中并不能解决所有问题,尤其是在协程嵌套层级较深的情况下。这时候,使用 CoroutineExceptionHandler 就可以轻松捕获整个作用域内的所有异常。

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

其实上面这 6 大准则,都跟协程的结构化并发有着密切联系。由于协程之间存在父子关系,因此它的异常处理也是遵循这一规律的。而协程的异常处理机制之所以这么复杂,也是因为它的结构化并发特性。

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

2017-02-08

posted @ 2017-02-08 16:33  白乾涛  阅读(1955)  评论(0编辑  收藏  举报