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
本文来自博客园,作者:白乾涛,转载请注明原文链接:https://www.cnblogs.com/baiqiantao/p/6378850.html