• Kotlin 朱涛23 协程 异常 trycatch 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

  • 相关阅读:
    对OpenCV学习笔记(1)遗留问题的思考
    转:争论32bit/64bit的人都搞错了方向,需要分清楚IA64和x64
    Win8_64bit+VS2012下的OpenCV学习笔记(1)
    pikachu练习平台-不安全的文件下载
    pikachu练习平台-文件包含漏洞(Files Inclusion)
    pikachu练习平台-RCE(远程系统命令、代码执行)
    pikachu练习平台(SQL注入 )
    pikachu练习平台(CSRF(跨站请求伪造) )
    pikachu练习平台(XSS-漏洞测试案例(cookie的窃取和利用、钓鱼攻击、XSS获取键盘记录))
    pikachu练习平台(XSS(跨站脚本))
  • 原文地址:https://www.cnblogs.com/baiqiantao/p/6378850.html
Copyright © 2020-2023  润新知