协程和协程的等级并不相同→协程的异常处理
这篇文章打算说一说协程的异常处理。
普通函数的异常处理⌗
相信大家对于这个问题再熟悉不过了。
使用try
和catch
来进行异常的捕获,在JVM上,未经捕获的异常会被传递给Thread.uncaughtExceptionHandler()
(下面简称UEH)来处理,结果大家应该也都很明白了,我们的程序会直接崩溃啦。
fun main() {
try {
codeThatMayThrowException()
} catch (t: Throwable) {
// ...
}
}
挂起函数的异常处理⌗
挂起函数内的错误处理其实和普通函数别无二至,但是挂起函数的错误,会被其所在的协程处理,这就涉及到下一部分的内容了。
suspend fun someFunction() {
try {
codeThatMayThrowException()
} catch (t: Throwable) {
// ...
}
}
协程的异常处理⌗
协同作用域下的协程(Job)⌗
通常情况下,Kotlin的协程是运行在协同作用域下的,借用《深入理解Kotlin协程》中的一句话,就是:
“子异常则父连坐”
即当子协程抛出未经捕获的异常时,父协程会“被迫”取消所有子协程和自身:
GlobalScope.launch { // this: CoroutineScope
val child1 = launch { // this: CoroutineScope
error("Boom!")
}
child1.join()
println("Done!") // Won't reach and crash.
}
通过launch
构造器创建的协程,在遇到未经捕获的异常时会直接抛出给父协程处理,所以处理这些问题的方式就是直接捕获协程内的异常:
val child1 = launch { // this: CoroutineScope
try {
error("Boom!")
} catch(e: Exception) {
// handle the exception
}
}
如果交由父级来处理,则需要使用CoroutineExceptionHandler
,请注意,CoroutineExceptionHandler
必须配置给CoroutineScope
或最顶级协程(即直接在CoroutineScope
下启动的协程):
val exceptionHandler = CoroutineExceptionHandler {
_: CoroutineContext, t: Throwable ->
// handle exception
println("Caught $t")
}
GlobalScope.launch(exceptionHandler) { // this: CoroutineScope
val child1 = launch { // this: CoroutineScope
error("Boom!")
}
child1.join()
println("Done!") // Won't reach, but coroutine finished normally.
}
使用同样的CoroutineExceptionHandler
,错误示范:
GlobalScope.launch { // this: CoroutineScope
val child1 = launch(exceptionHandler) { // this: CoroutineScope
error("Boom!")
}
child1.join()
println("Done!") // Won't reach and crash.
}
上面的方法是不会正常工作的,在协同作用域下的协程,只有顶层协程有权利处理未经捕获的异常,子协程发生的所有未捕获异常,都会导致整个协同作用域的瓦解,即便用async
也是同样的结果:
GlobalScope.launch { // this: CoroutineScope
val task = async<Int> { // this: CoroutineScope
error("Boom!")
}
try {
// Even we don't call await(), it still crash.
task.await()
} catch(e: Exception) {
println("launch Caught: $e")
}
println("Done!")
}
和上面是同样的道理,async
发生了未经捕获的异常,但是由于处于协同作用域下,它无权处理此异常,必须向父级传递,传递给launch启动的协程时,因为launch已经是顶层协程,所以它将会处理这个异常。
顶层协程在处理异常时,会优先考虑使用CoroutineExceptionHandler
,但如果协程上下文中没有CoroutineExceptionHandler
,那么这个顶层协程只能将异常交由当前线程的Thread.uncaughtExceptionHandler()
来处理(JVM),此时程序就会崩溃啦。
为何要这样设计呢?
这样设计的目的是保证协程在遭遇错误时正确释放协程占用的资源。
主从作用域下的协程(SupervisorJob)⌗
但有些场景下,这不符合我们的需求,一个并发任务产生问题,我们不希望取消其他的并发任务,而只需要处理一下这个错误就可以了,这个时候,我们就需要断开这个错误的传递链。
⚔️ 看我,拿胜利宝剑,断开魂结,断开锁链,断开一切的牵连~
这时候就要介绍一下SupervisorJob
,它可以帮我们完成这个需求。
我们如何通过SupervisorJob
来断开异常传递呢?最简单的方式就是创建一个SupervisorJob()
为CoroutineContext
的CoroutineScope
,并在其内部启动协程,这样每个直接子协程就拥有了自己处理异常的权力:
val myScope = CoroutineScope(SupervisorJob())
// async throws excpetion.
// But myScope just accknowledged the exception and won't handle it.
val task = myScope.async<Int> { // this: CoroutineScope
error("Boom!")
}
val job = myScope.launch { // this: CoroutineScope
try {
// Because our async finished with an exception.
// The exception will be rethrown when we call await()
task.await()
} catch (e: Exception) {
println("launch Caught $e")
}
}
fun main() {
// finished normally
runBlocking { // this: CoroutineScope
println("Wait...")
job.join()
println("Finish.")
}
}
但是这个方法有些麻烦,有时候我们只是做一些简单的并发,所以协程框架为我们提供了supervisorScope()
这个函数。
这个函数是一个挂起函数,会帮我们创建一个带有SupervisorJob
的CoroutineScope
,此函数会挂起(suspend
),直到这个CoroutineScope
下启动的所有协程结束后恢复(resume
):
suspend fun <T, R> mapParallel(items: List<T>, transform: suspend (T) -> R): List<R> {
if (items.isEmpty()) {
// Fast path
return emptyList()
}
return supervisorScope { // this: CoroutineScope
val tasks = items.map { item ->
async { // this: CoroutineScope
transform(item)
}
}
try {
tasks.awaitAll()
} catch (e: Exception) {
emptyList()
}
}
}
我们用上面这个函数来进行一个并发的map
操作:
fun main() = runBlocking {
val source = listOf(1, 2, 3, 4)
val result = mapParallel(source) { // it: Int
delay(100)
"mapped($it)"
}
println(result) // [mapped(1), mapped(2), mapped(3), mapped(4)]
}
没有异常的情况下,正常输出了map
后的结果,如果我们加入一点异常情况:
val result = mapParallel(source) { // it: Int
delay(100)
if (it == 3) {
error("I hate 3.")
}
"mapped($it)"
}
因为我们已经在mapParallel
内进行了错误处理,所以此处我们可以得到正确结果:[]
(一个空列表)。
是不是非常方便?但是在使用SupervisorJob
时,有一个需要注意的地方,只有SupervisorJob
的直接子协程才能获得处理异常的权力,下级的子协程仍会将异常向上传递。
我们知道每个协程在被创建时,都创建了Job
作为生命周期标识(AbstractCoroutine
实现了Job
接口),一旦下层的异常传递到SupervisorJob
下的子协程,其内部的Job
仍会以协同作用域方式处理异常,如果此时没有CoroutineExceptionHandler
来处理异常,异常将会被传递给Thread
的Thread.uncaughtExceptionHandler()
,程序仍然会崩溃:
fun main() {
runBlocking {
supervisorScope { // this: CoroutineScope [SupervisorJob]
launch { // this: CoroutineScope [Job]
// will crash
val task = async<Int> { // this: CoroutineScope
error("Boom!")
}
}
}
}
}
简单的总结⌗
- 对于事务性的操作,最好使用协同作用域,确保一个并发任务的所有部分都正确完成
- 对于子任务间相对隔离的操作,可以考虑使用主从作用域,给予子协程处理异常的权力
- 处理异常的场景下,最好使用
CoroutineExceptionHandler
参考资料⌗
-
《深入理解Kotlin协程》 —— 霍丙乾
-
Exceptional Exceptions for Coroutines made easy…?
-
Exceptions in coroutines