【Kotlin回顾】21.Kotlin协程—异常

169 阅读6分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第21天,点击查看活动详情

1.cancel()不能被响应

fun catchTest() = runBlocking {

    val job = launch(Dispatchers.IO) {
        var i = 0
        while (true) {
            i++
        }
    }

    job.log()
    delay(2000L)
    job.log()
    job.cancel()
    job.log()

    println("End")
}

fun Job.log() {
    println(
        """
        isActive:$isActive
        isCompleted:$isCompleted
        isCancelled:$isCancelled
        Thread:${Thread.currentThread().name}  
        ================================
    """.trimIndent()
    )
}

//输出结果:
//isActive:true
//isCompleted:false
//isCancelled:false
//Thread:main  
//================================
//isActive:true
//isCompleted:false
//isCancelled:false
//Thread:main  
//================================
//isActive:false
//isCompleted:false
//isCancelled:true
//Thread:main  
//================================
//End

上面的代码通过launch启动了一个协程,在协程中执行一个死循环,2000毫秒后取消任务的执行,isCancelled = true说明job确实被取消了,但是从代码运行结果来看这个job并没有被结束,换句话说就是cancel()没有起作用,这是为什么呢?分析其原因就是因为调用cancel()后协程任务已经变得不在活跃了,但是代码并没有把isActive作为循环条件,因此此任务不能被取消。

之前也有聊到过,协程就是互相协作的程序,在外部取消任务的同时内部其实也是需要做出响应的,具体来说就是要在内部加入isActive的判断,通过协程的运行状态决定是否继续执行任务:

fun catchTest() = runBlocking {

    val job = launch(Dispatchers.IO) {
        var i = 0
//			 变化在这里
//               ↓        
        while (isActive) {
            i++
        }
    }

    job.log()
    delay(2000L)
    job.log()
    job.cancel()
    job.log()

    println("End")
}

//输出结果:
//isActive:true
//isCompleted:false
//isCancelled:false
//Thread:main  
//================================
//isActive:true
//isCompleted:false
//isCancelled:false
//Thread:main  
//================================
//isActive:false
//isCompleted:false
//isCancelled:true
//Thread:main  
//================================
//End
//
//Process finished with exit code 0

加入isActive的循环后任务就可以正常取消了,这个异常用一句话总结:协程的在外部能顺利取消的必要条件是需要内部的配合。

2.结构被破坏

协程是具有结构化的,当父协程被取消时子协程也会跟着被取消,但是总有个例外不是,这个例外就是结构被破坏。

val myFixedDispatcher = Executors.newFixedThreadPool(3) {
    Thread(it, "myFixedDispatcher").apply { isDaemon = false }
}.asCoroutineDispatcher()

fun catchTest() = runBlocking {

    val parentLaunch = launch(myFixedDispatcher) {
        launch(Job()) {
            var i = 0
            while (isActive) {
                delay(1000L)
                i++
                println("launch1 i: $i")
            }
        }

        launch {
            var i = 0
            while (isActive) {
                delay(1000L)
                i++
                println("launch2 i: $i")
            }
        }

        launch {
            var i = 0
            while (isActive) {
                delay(1000L)
                i++
                println("launch3 i: $i")
            }
        }
    }

    delay(2000L)

    parentLaunch.cancel()
    println("Process end!")
}

//输出结果:
//launch1 i: 1
//launch2 i: 1
//launch3 i: 1
//Process end!
//launch1 i: 2
//launch1 i: 3
//launch1 i: 4
//launch1 i: 5
//launch1 i: 6
//launch1 i: 7

上面的代码定义了一个parentLaunch,在它的里面又定义了三个launch,这是一个结构化的代码,按照之前在Job中聊过的一些信息可以知道当parentLaunch调用cancel()方法后子协程都会停止执行,但是在第一个launch中加入了一个Job,就导致第一个子launch一直在运行了,这主要是因为原本和谐的结构被破坏了,画张图对比一下:

从两张对比图可以看出,结构被破坏的launch已经不属于parentLaunch的子job了,所以也就可以理解当parentLaunch取消后launch1还在执行任务。而如果想要正常执行则要删除launch1的Job即可。

通过上面的分析可以得出一个结论:不要轻易打破协程的父子结构。

3.异常捕获后要抛出来

前面在聊协程的并发操作——Mutex时特地弄出一个i / 0的异常,同时也将其捕获了,但是程序并没有停止而是卡住了,原因就是异常被捕获后没有抛出

fun concurrentTest() = runBlocking {
    val time = measureTimeMillis {
        val jobs = mutableListOf<Job>()
        var i = 0
        val mutex = Mutex()

        repeat(10) {
            val job = launch(Dispatchers.Default) {
                repeat(1000) {
                    try {
                        mutex.lock()
                        i++
                        i / 0
                        mutex.unlock()
                    } catch (e: Exception) {
                        e.printStackTrace()
                        throw e				//变化在这里
                    }
                }
            }
            jobs.add(job)
        }

        jobs.joinAll()

        println("i: $i")
    }
    println("time: $time")
}

上面的代码就是Mutex的案例,只是在捕获之后加了throw e程序就可以停止执行了。所以这里还可以得出一个结论:捕获异常后要考虑是否要将异常抛出。这里还有一点要注意,try-catch的异常用的是Exception,它是所有异常的父类,用它的话会影响效率,正确的应该是使用指定异常,这在Java中也是要这么做的,那么上面的Exception就可以修改为ArithmeticException

4.协程中的try-catch失效

在Java开发中有时候会用try-catch将代码都包起来,那么这样的做法在协程中是否有效呢

上面的代码简化一下,来验证一下try-catch是否真的失效了

fun catchTest() = runBlocking {
    var i = 0
       var i = 0
    try {
        launch {
            i++
            i / 0
        }
    } catch (e: ArithmeticException) {
        println("catch: $e")
    }

    println("end")
}

//输出结果:
//end

java.lang.ArithmeticException: / by zero

异常没有被捕获了且程序报错,在用async试试

fun catchTest() = runBlocking {
    var i = 0

    try {
        val deferred = async {
            i++
            i / 0
        }

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

    println("end")
}

java.lang.ArithmeticException: / by zero
//输出结果:
//end
//catch: java.lang.ArithmeticException: / by zero

async依旧会报错,还有个疑问,已知async只有调用了await才会返回结果,那是不是可以理解为不调用await就不会报错呢?

fun catchTest() = runBlocking {
    var i = 0

    try {
        val deferred = async {
            i++
            i / 0
        }

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

    println("end")
}

//输出结果:
//catch: java.lang.ArithmeticException: / by zero
//end

java.lang.ArithmeticException: / by zero

依旧会报错,那么为什么try-catch会失效呢?这主要是跟代码执行顺序有关,因为,当协程体当中的“i / 0”执行的时候,我们的程序已经跳出 try-catch 的作用域了。

5.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的子类,它与Job的区别就在于当父Job中的某个子Job发生异常的时候其他的子Job不会受到牵连。用两段代码做个对比:

  • 没有使用SupervisorJob
fun catchTest() = runBlocking {

    val parentJob = launch {

        launch {
            println("satrt launch1")
            delay(1000L)
            1 / 0                   //刻意制造一个错误
            println("end launch1")
        }

        launch {
            println("satrt launch2")
            delay(1000L)
            println("end launch2")
        }

        launch {
            println("satrt launch3")
            delay(1000L)
            println("end launch3")
        }
    }

    delay(2000L)
}

//输出结果:
//satrt launch1
//satrt launch2
//satrt launch3

Exception in thread "main" java.lang.ArithmeticException: / by zero

Process finished with exit code 0

上面这段代码是没有使用SupervisorJob的,从输出日志可以看到当launch1出现错误后程序会终止,后面的launch2、launch3的任务也会跟着终止。

  • 使用SupervisorJob
fun catchTest() = runBlocking {
    val scope = CoroutineScope(SupervisorJob())

    scope.launch {

         launch {
            println("satrt launch1")
            delay(1000L)
            1 / 0                           //刻意制造一个错误
            println("end launch1")
        }

        launch {
            println("satrt launch2")
            delay(1000L)
            println("end launch2")
        }

        launch {
            println("satrt launch3")
            delay(1000L)
            println("end launch3")
        }
    }

    delay(2000L)

}

//输出结果:
//satrt launch1
//satrt launch2
//satrt launch3
//end launch2
//end launch3

Exception in thread "DefaultDispatcher-worker-2 @coroutine#3" java.lang.ArithmeticException: / by zero

Process finished with exit code 0

上面这段代码是使用了SupervisorJob的,从输出日志可以看到当launch1出现错误后程序并不会终止,后面的launch2、launch3的任务会执行完毕。

这里又得出一条结论:合理使用SupervisorJob可提高代码执行完成度,降低异常传播范围。 流程图表示如下

6.CoroutineExceptionHandler

前面总结了几种异常处理办法,都是针对某一个具体的异常处理,如果在复杂的业务场景中这几种方式处理起来就比较麻烦了,此时就要使用Kotlin提供的CoroutineExceptionHandler异常处理方案,现在修改一下上面使用了SupervisorJob()的代码,给它多加几个错误:

fun catchTest() = runBlocking {
    val scope = CoroutineScope(SupervisorJob())

    val parentJob = scope.launch {

        launch {
            println("satrt launch1")
            delay(1000L)
            1 / 0                           //刻意制造一个错误
            println("end launch1")
        }

        launch {
            println("satrt launch2")
            delay(1000L)
            1 / 0                           //刻意制造一个错误
            println("end launch2")
        }

        launch {
            println("satrt launch3")
            delay(1000L)
            1 / 0                           //刻意制造一个错误
            println("end launch3")
        }
    }

    delay(2000L)
}

上面的代码在launch1、launch2、launch3中都有一个错误,使用SupervisorJob()时再日志中就会输出三个错误信息/ by zero,如果使用try-catch就需要在每一个launch的错误处添加try-catch,就像下面这样:

fun catchTest() = runBlocking {
    val scope = CoroutineScope(SupervisorJob())

    scope.launch {

        launch {
            println("satrt launch1")
            delay(1000L)
            try {
                1 / 0                           //刻意制造一个错误
            } catch (e: ArithmeticException) {
                println("launch1: $e")
            }
            println("end launch1")
        }

        launch {
            println("satrt launch2")
            delay(1000L)
            try {
                1 / 0                           //刻意制造一个错误
            } catch (e: ArithmeticException) {
                println("launch2: $e")
            }
            println("end launch2")
        }

        launch {
            println("satrt launch3")
            delay(1000L)
            try {
                1 / 0                           //刻意制造一个错误
            } catch (e: ArithmeticException) {
                println("launch3: $e")
            }
            println("end launch3")
        }
    }

    delay(2000L)
}

//输出结果:
//satrt launch1
//satrt launch2
//satrt launch3
//launch1: java.lang.ArithmeticException: / by zero
//end launch1
//launch2: java.lang.ArithmeticException: / by zero
//launch3: java.lang.ArithmeticException: / by zero
//end launch3
//end launch2

问题解决但是太啰嗦,而且前面也聊过try-catch放在全局会失效。

那么用CoroutineExceptionHandler的处理方式如下:

fun catchTest() = runBlocking {
    val exception = CoroutineExceptionHandler { _, throwable ->
        println("catch: $throwable")
    }

    val scope = CoroutineScope(exception)

    scope.launch {

        launch {
            println("satrt launch1")
            delay(1000L)
            1 / 0                           //刻意制造一个错误
            println("end launch1")
        }

        launch {
            println("satrt launch2")
            delay(1000L)
            1 / 0                           //刻意制造一个错误
            println("end launch2")
        }

        launch {
            println("satrt launch3")
            delay(1000L)
            1 / 0                           //刻意制造一个错误
            println("end launch3")
        }
    }

    delay(2000L)
}

//输出结果:
//satrt launch1
//satrt launch2
//satrt launch3
//catch: java.lang.ArithmeticException: / by zero

这样就可以发现CoroutineExceptionHandler的优势所在了。但是CoroutineExceptionHandler在使用时也会存在问题,比如说只把CoroutineExceptionHandler添加到报错的launch中,而不添加到父launch中,这样会不会有用呢?比如说下面这样:

fun catchTest() = runBlocking {
    val exception = CoroutineExceptionHandler { _, throwable ->
        println("catch: $throwable")
    }
//								这里做了修改
    val scope = CoroutineScope(coroutineContext)

    scope.launch {

        launch {
            println("satrt launch1")
            delay(1000L)
            println("end launch1")
        }

//				exception移到了这里
        launch(exception) {
            println("satrt launch2")
            delay(1000L)
            1 / 0                           //刻意制造一个错误
            println("end launch2")
        }

        launch {
            println("satrt launch3")
            delay(1000L)
            println("end launch3")
        }
    }

    delay(2000L)

}

//输出结果:
//satrt launch1
//satrt launch2
//satrt launch3
Exception in thread "main" java.lang.ArithmeticException: / by zero
//end launch1

从日志就可以知道答案:不会,因为CoroutineExceptionHandler只在顶层的协程中才会起作用,这里引用CoroutineExceptionHandler注释中的一句话:所有的子协程(在另一个Job的上下文中创建的协程)将它们的异常处理委托给它们的父协程,父协程也委托给父协程,以此类推,直到根,所以安装在它们上下文中的CoroutineExceptionHandler永远不会被使用。与supervise job一起运行的协程不会将异常传播给它们的父协程,并被视为根协程。