kotlin 协程的异常处理
概述
协程是互相协作的程序,协程是结构化的。
正是因为协程的这两个特点,导致它和 java 的异常处理机制不一样。如果将 java 的异常处理机制照搬到kotlin协程中,会遇到很多问题,如:协程无法取消、try-catch不起作用等。
kotlin协程中的异常主要分两大类
- 协程取消异常(cancellationexception)
- 其他异常
异常处理六大准则
- 协程的取消需要内部配合。
- 不要打破协程的父子结构。
- 捕获 cancellationexception 异常后,需要考虑是否重新抛出来。
- 不要用 try-catch 直接包裹 launch、async。
- 使用 surpervisorjob 控制异常传播的范围。
- 使用 coroutineexceptionhandler 处理复杂结构的协程异常,仅在顶层协程中起作用。
核心理念:协程是结构化的,异常传播也是结构化的。
准则一:协程的取消需要内部配合
协程任务被取消时,它的内部会产生一个 cancellationexception 异常,协程的结构化并发的特点:如果取消了父协程,则子协程也会跟着取消。
问题:cancel不被响应
fun main() = runblocking { val job = launch(dispatchers.default) { var i = 0 while (true) { thread.sleep(500l) i println("i: $i") } } delay(200l) job.cancel() job.join() println("end") } /* 输出信息: i: 1 i: 2 i: 3 i: 4 // 不会停止,一直打印输出 */
原因:协程是相互协作的程序,因此协程任务的取消也需要相互协作。协程外部取消,协程内部需要做出相应。
解决:使用 isactive 判断是否处于活跃状态
使用 isactive 判断协程的活跃状态。
fun main() = runblocking { val job = launch(dispatchers.default) { var i = 0 // 关键 // ↓ while (isactive) { thread.sleep(500l) i println("i: $i") } } delay(200l) job.cancel() job.join() println("end") } /* 输出信息: i: 1 end */
准则二:不要打破协程的父子结构
问题:子协程不会跟随父协程一起取消
val fixeddispatcher = executors.newfixedthreadpool(2) { thread(it, "myfixedthread") }.ascoroutinedispatcher() fun main() = runblocking { // 父协程 val parentjob = launch(fixeddispatcher) { //子协程1 launch(job()) { var i = 0 while (isactive) { thread.sleep(500l) i println("子协程1 i:$i") } } //子协程2 launch { var i = 0 while (isactive) { thread.sleep(500l) i println("子协程2 i:$i") } } } delay(1000l) parentjob.cancel() parentjob.join() println("end") } /* 输出信息: 子协程1 i:1 子协程2 i:1 子协程2 i:2 子协程1 i:2 end 子协程1 i:3 子协程1 i:4 子协程1 i:5 // 子协程1一直在执行,不会停下来 */
原因:协程是结构化的,取消啦父协程,子协程也会被取消。但是在这里“子协程1”不在 parentjob 的子协程,打破了原有的结构化关系,当调用 parentjob.cancel 时,“子协程1”就不会被取消了。
解决:不破坏父子结构
“子协程1”不要传入额外的 job()。
fun main() = runblocking { val parentjob = launch(fixeddispatcher) { launch { var i = 0 while (isactive) { thread.sleep(500l) i println("子协程1:i= $i") } } launch { var i = 0 while (isactive) { thread.sleep(500l) i println("子协程2:i= $i") } } } delay(2000l) parentjob.cancel() parentjob.join() println("end") } /* 输出结果: 子协程1:i= 1 子协程2:i= 1 子协程2:i= 2 子协程1:i= 2 子协程1:i= 3 子协程2:i= 3 子协程1:i= 4 子协程2:i= 4 end */
准则三:捕获 cancellationexception 需要重新抛出来
挂起函数可以自动响应协程的取消
kotlin 中的挂起函数是可以自动响应协程的取消,如下中的 delay() 函数可以自动检测当前协程是否被取消,如果已经取消了它就会抛出一个 cancellationexception,从而终止当前协程。
fun main() = runblocking { // 父协程 val parentjob = launch(dispatchers.default) { //子协程1 launch { var i = 0 while (true) { // 这里 delay(500l) i println("子协程1 i:$i") } } //子协程2 launch { var i = 0 while (true) { // 这里 delay(500l) i println("子协程2 i:$i") } } } delay(1000l) parentjob.cancel() parentjob.join() println("end") } /* 输出信息: 子协程1 i:1 子协程2 i:1 子协程1 i:2 子协程2 i:2 end */
fun main() = runblocking { // 父协程 val parentjob = launch(dispatchers.default) { //子协程1 launch { var i = 0 while (true) { try { delay(500l) } catch (e: cancellationexception) { println("捕获cancellationexception") throw e } i println("子协程1 i:$i") } } //子协程2 launch { var i = 0 while (true) { try { delay(500l) } catch (e: cancellationexception) { println("捕获cancellationexception") throw e } i println("子协程2 i:$i") } } } delay(1000l) parentjob.cancel() parentjob.join() println("end") } /* 输出信息: 子协程1 i:1 子协程2 i:1 捕获cancellationexception 捕获cancellationexception end */
问题:捕获 cancellationexception 导致崩溃
fun main() = runblocking { val parentjob = launch(dispatchers.default) { launch { var i = 0 while (true) { try { delay(500l) } catch (e: cancellationexception) { println("捕获cancellationexception异常") } i println("子协程1 i= $i") } } launch { var i = 0 while (true) { delay(500l) i println("子协程2 i= $i") } } } delay(2000l) parentjob.cancel() parentjob.join() println("end") } /* 输出信息: 子协程1 i= 1 子协程2 i= 1 子协程1 i= 2 子协程2 i= 2 子协程1 i= 3 子协程2 i= 3 捕获cancellationexception异常 ...... //程序不会终止 */
原因:当捕获到 cancellationexception 以后,还需要将它重新抛出去,如果没有抛出去则子协程将无法取消。
解决:需要重新抛出
重新抛出异常,执行 throw e
。
以上三条准则,都是应对 cancellationexception 这个特殊异常的。
fun main() = runblocking { val parentjob = launch(dispatchers.default) { launch { var i = 0 while (true) { try { delay(500l) } catch (e: cancellationexception) { println("捕获cancellationexception异常") // 抛出异常 throw e } i println("协程1 i= $i") } } launch { var i = 0 while (true) { delay(500l) i println("协程2 i= $i") } } } delay(2000l) parentjob.cancel() parentjob.join() println("end") } /* 输出信息: 协程1 i= 1 协程2 i= 1 协程2 i= 2 协程1 i= 2 协程2 i= 3 协程1 i= 3 捕获cancellationexception异常 end */
准则四:不要用try-catch直接包裹launch、async
问题:try-catch不起作用
fun main() = runblocking { try { launch { delay(100l) 1 / 0 //产生异常 } } catch (e: arithmeticexception) { println("捕获:$e") } delay(500l) println("end") } /* 输出信息: exception in thread "main" java.lang.arithmeticexception: / by zero */
原因:协程的代码执行顺序与普通程序不一样,当协程执行 1 / 0
时,程序实际已经跳出 try-catch 的作用域了,所以直接使用 try-catch 包裹 launch、async 是没有任何效果的。
解决:调整作用域
可以将 try-catch 移动到协程体内部,这样可以捕获到异常了。
fun main() = runblocking { launch { delay(100l) try { 1 / 0 //产生异常 } catch (e: arithmeticexception) { println("捕获异常:$e") } } delay(500l) println("end") } /* 输出信息: 捕获异常:java.lang.arithmeticexception: / by zero end */
准则五:灵活使用surpervisorjob
问题:子job发生异常影响其他子job
fun main() = runblocking { launch { launch { 1 / 0 delay(100l) println("hello world 111") } launch { delay(200l) println("hello world 222") } launch { delay(300l) println("hello world 333") } } delay(1000l) println("end") } /* 输出信息: exception in thread "main" java.lang.arithmeticexception: / by zero */
原因:使用普通 job 时,当子job发生异常时,会导致 parentjob 取消,从而导致其他子job也受到牵连,这也是协程结构化的体现。
解决:使用 supervisorjob
surpervisorjob 是 job 的子类,surpervisorjob 是一个种特殊的 job,可以控制异常的传播范围,当子job发生异常时,其他的子job不会受到影响。
将 parentjob 改为 supervisorjob。
fun main() = runblocking { val scope = coroutinescope(supervisorjob()) scope.launch { 1 / 0 delay(100l) println("hello world 111") } scope.launch { delay(200l) println("hello world 222") } scope.launch { delay(300l) println("hello world 333") } delay(1000l) println("end") } /* 输出信息: exception in thread "defaultdispatcher-worker-1 @coroutine#2" java.lang.arithmeticexception: / by zero hello world 222 hello world 333 end */
解决:使用 supervisorscope
supervisorscope 底层依然使用的是 supervisorjob。
fun main() = runblocking { supervisorscope { launch { 1 / 0 delay(100l) println("hello world 111") } launch { delay(200l) println("hello world 222") } launch { delay(300l) println("hello world 333") } } delay(1000l) println("end") } /* 输出信息: exception in thread "main" java.lang.arithmeticexception: / by zero hello world 222 hello world 333 end */
准则六:使用 coroutineexceptionhandler 处理复杂结构的协程异常
问题:复杂结构的协程异常
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" java.lang.arithmeticexception: / by zero */
原因:模拟一个复杂的协程嵌套场景,开发人员很难在每一个协程体中写 try-catch,为了捕获异常,可以使用 coroutineexceptionhandler。
解决:使用coroutineexceptionhandler
使用 coroutineexceptionhandler 处理复杂结构的协程异常,它只能在顶层协程中起作用。
fun main() = runblocking { val mycoroutineexceptionhandler = coroutineexceptionhandler { _, throwable -> println("捕获异常:$throwable") } val scope = coroutinescope(coroutinecontext job() mycoroutineexceptionhandler) scope.launch { async { delay(100l) } launch { delay(100l) launch { delay(100l) 1 / 0 } } delay(100l) } delay(1000l) println("end") } /* 输出信息: 捕获异常:java.lang.arithmeticexception: / by zero end */
总结
- 准则一:协程的取消需要内部的配合。
- 准则二:不要轻易打破协程的父子结构。协程的优势在于结构化并发,他的许多特性都是建立在这之上的,如果打破了它的父子结构,会导致协程无法按照预期执行。
- 准则三:捕获 cancellationexception 异常后,需要考虑是否重新抛出来。协程是依赖 cancellationexception 异常来实现结构化取消的,捕获异常后需要考虑是否重新抛出来。
- 准则四:不要用 try-catch 直接包裹 launch、async。协程代码的执行顺序与普通程序不一样,直接使用 try-catch 可能不会达到预期效果。
- 准则五:使用 supervisorjob 控制异常传播范围。supervisorjob 是一种特殊的 job,可以控制异常的传播范围,不会受到子协程中的异常而取消自己。
- 准则六:使用 coroutineexceptionhandler 捕获异常。当协程嵌套层级比较深时,可以在顶层协程中定义 coroutineexceptionhandler 捕获整个作用域的所有异常。