Kotlin 协程 (六) ——— 协程异常处理:CoroutineExceptionHandler

1,340 阅读5分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

一、协程异常简介

协程中的异常分为两种:一种是在协程内部直接抛出异常,另一种则是在协程启动处抛出异常。

举个例子:

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 中,可以定义一个协程全局异常处理器,它可以用于调试和异常上报。