本文已参与「新人创作礼」活动,一起开启掘金创作之路。
一、协程异常简介
协程中的异常分为两种:一种是在协程内部直接抛出异常,另一种则是在协程启动处抛出异常。
举个例子:
fun main() = runBlocking<Unit> {
val job = GlobalScope.launch {
try {
throw Exception()
} catch (e: Exception) {
println("catch in coroutine")
}
}
job.join()
val deferred = GlobalScope.async {
throw Exception()
}
try {
deferred.await()
} catch (e: Exception) {
println("catch in await")
}
}
在这段代码中,使用 GlobalScope.launch 创建的协程发生异常时,只能在协程内部捕捉到。使用 GlobalScope.async 创建的协程发生异常时,不仅可以在内部捕捉到,还可以在 await 处捕捉到。
运行程序,输出如下:
catch in coroutine
catch in await
Kotlin 协程中,将第一种异常传播方式称为自动传播异常(launch/actor),特点是发生异常后立即抛出。将第二种异常传播方式称为向用户暴露异常(async/produce),特点是在用户启动协程时才会抛出异常。
需要注意的是,并不是使用 async/produce 创建的协程产生的异常都会向用户暴露。异常如何传播还跟当前协程是否是根协程有关。
根协程也就是非子协程的协程,根协程和子协程的异常传播规则如下:
- 根协程的 launch/actor 函数创建的协程会自动传播异常,async/produce 函数创建的协程会向用户暴露异常。
- 非根协程中,产生的异常总是会自动传播。
@Test
fun test() = runBlocking<Unit> {
// 根协程 launch 创建的协程,第一时间抛出异常
val globalLaunchJob = GlobalScope.launch {
try {
throw Exception()
} catch (e: Exception) {
println("catch exception in GlobalScope.launch")
}
}
// 根协程 async 创建的协程,await 时才抛出异常
val globalAsyncJob = GlobalScope.async {
throw Exception()
}
try {
globalAsyncJob.await()
} catch (e: Exception) {
println("catch exception when globalAsyncJob.await()")
}
// 非根协程 launch 创建的协程,第一时间抛出异常
val launchJob = launch {
try {
throw Exception()
} catch (e: Exception) {
println("catch exception in launch")
}
}
// 非根协程 async 创建的协程,第一时间抛出异常
val asyncJob = async {
try {
throw Exception()
} catch (e: Exception) {
println("catch exception in async")
}
}
asyncJob.await()
}
在这段代码中,只有 GlobalScope.async 开启的协程抛出的异常可以在 await() 时捕获到,其他的协程抛出的异常只能在抛出时捕获到。
运行程序,输出如下:
catch exception in GlobalScope.launch
catch exception when globalAsyncJob.await()
catch exception in launch
catch exception in async
二、异常的传播特性
当一个协程自己抛出异常时,它的所有子协程都会被取消。
fun main() {
runBlocking {
supervisorScope {
launch {
try {
println("The child is sleeping")
delay(Long.MAX_VALUE)
} finally {
println("The child is cancelled")
}
}
yield()
println("Throwing an exception from the scope")
throw Exception()
}
}
}
运行程序,输出如下:
The child is sleeping
Throwing an exception from the scope
The child is cancelled
Exception in thread "main" java.lang.Exception...
当一个子协程抛出异常时,它会将异常传播到它的父级。如果这个异常不是取消异常的话,那么父级收到异常后,会做三件事:
- 取消所有的子级。
- 取消自己。
- 将异常传播给它的父级。
如果子协程抛出的异常是取消异常,父协程不会被取消,而是直接忽略掉,因为取消异常是个「正常」的异常。
使用 SupervisorJob 时,一个子协程的运行失败不会影响到其他子协程。也就是说,SupervisorJob 不会将异常传播给父级,它会让子协程自己处理异常。
三、异常的捕获
上一篇文章我们讲到,协程上下文中的 CoroutineExceptionHandler 是用来捕获协程未处理异常的。
这个类很简单,传入的是一个 lambda 表达式,源码如下:
public inline fun CoroutineExceptionHandler(crossinline handler: (CoroutineContext, Throwable) -> Unit): CoroutineExceptionHandler =
object : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler {
override fun handleException(context: CoroutineContext, exception: Throwable) =
handler.invoke(context, exception)
}
CoroutineExceptionHandler 捕获异常需要两个条件:
- 这个异常是自动传播的。
- CoroutineExceptionHandler 位于 CoroutineScope 的 CoroutineContext 中,或 supervisorScope 的直接子协程中,或其他根协程中。
这样的设计是合理的,因为 CoroutineScope 的子协程不应该捕获异常,CoroutineScope 的设定就是子协程的异常交由父类处理,所以应该在 CoroutineScope 创建的根协程中捕获此异常。而 supervisorScope 的设定是子协程的异常自己处理,所以 supervisorScope 的子协程可以自己捕获异常。
看一个例子:
fun main() {
runBlocking {
val handler = CoroutineExceptionHandler { _, e ->
println("Caught $e")
}
val scope = CoroutineScope(Job())
val job = scope.launch(handler) {
throw Exception()
}
job.join()
}
}
运行程序,输出如下:
Caught java.lang.Exception
这个异常之所以能被捕获,就是因为 handler 是放在 scope.launch 中的,scope.launch 创建的协程属于根协程。虽然我们抛出异常是在 scope 的子协程中,但子协程的异常会抛到父协程中处理,所以成功捕获了异常。
而这个例子就不能捕获到异常:
fun main() {
runBlocking {
val handler = CoroutineExceptionHandler { _, e ->
println("Caught $e")
}
val scope = CoroutineScope(Job())
val job = scope.launch {
launch(handler) {
throw Exception()
}
}
job.join()
}
}
运行程序,输出如下:
Exception in thread "DefaultDispatcher-worker-2" java.lang.Exception...
唯一的区别是把 handler 放在了 scope 子协程的子协程中,这时异常会往父协程抛出,不会被自己捕获。
supervisorScope 的直接子协程是可以捕获到异常的,因为这些 supervisorScope 的子协程需要自己处理异常。
fun main() {
runBlocking {
val handler = CoroutineExceptionHandler { _, e ->
println("Caught $e")
}
val scope = supervisorScope {
launch(handler) {
throw Exception()
}
}
}
}
运行程序,输出如下:
Caught java.lang.Exception
与 supervisorScope 不同的是,coroutineScope 的直接子协程不能捕捉到异常:
fun main() {
runBlocking {
val handler = CoroutineExceptionHandler { _, e ->
println("Caught $e")
}
coroutineScope {
launch(handler) {
throw Exception()
}
}
}
}
运行程序,输出如下:
Exception in thread "DefaultDispatcher-worker-2" java.lang.Exception...
四、Android 中,Kotiln 协程全局异常处理器
Kotiln 协程全局异常处理器可以获取到所有协程未处理的未捕获异常,不过它并不能对异常进行捕获,不能阻止程序崩溃。但在程序调试和异常上报等场景中非常有用。
想要使用全局异常处理器,需要在 classpath (默认是 src/main 文件夹) 下面创建 META-INF/services 目录,并在其中创建一个名为 kotlinx.coroutines.CoroutineExceptionHandler 的文件,文件内容就是全局异常处理器的全类名。
class GlobalCoroutineExceptionHandler(override val key: CoroutineContext.Key<*> = CoroutineExceptionHandler) : CoroutineExceptionHandler {
override fun handleException(context: CoroutineContext, exception: Throwable) {
Log.d("~~~", "Unhandled Coroutine Exception: $exception")
}
}
五、异常聚合
当多个子协程因为异常而失败时,一般情况下取第一个异常进行处理。在第一个异常之后发生的所有其他异常,都将被绑定到第一个异常上。也就是存储于 exception.suprressed 数组中。
举个例子:
fun main() {
runBlocking {
val handler = CoroutineExceptionHandler { _, e ->
println("Caught $e, suppressed: ${e.suppressed.contentToString()}")
}
val job = GlobalScope.launch(handler) {
launch {
try {
delay(Long.MAX_VALUE)
} finally {
throw IllegalArgumentException("I'm IllegalArgument")
}
}
launch {
try {
delay(Long.MAX_VALUE)
} finally {
throw ArithmeticException("I'm Arithmetic")
}
}
launch {
throw IOException("I'm IO")
}
}
job.join()
}
}
运行程序,输出如下:
Caught java.io.IOException: I'm IO, suppressed: [java.lang.IllegalArgumentException: I'm IllegalArgument, java.lang.ArithmeticException: I'm Arithmetic]
六、小结
本文讲解了协程的异常处理器,协程异常的传播与协程的类型、协程的创建方式都有关。
在 Android 中,可以定义一个协程全局异常处理器,它可以用于调试和异常上报。