协程06 - 异常处理

在协程中,异常的传播形式有两种:

  • 一种是自动传播( launchactor)
  • 一种是向用户暴露该异常( asyncproduce )

这两种的区别在于,前者的异常传递过程是层层向上传递(如果异常没有被捕获),而后者将不会向上传递,会在调用处直接暴漏。

CoroutineExceptionHandler

其是用于在协程中全局捕获异常行为的最后一种机制,你可以理解为,类似 [Thread.uncaughtExceptionHandler] 一样。但需要注意的是,CoroutineExceptionHandler 仅在未捕获的异常上调用。

当子协程发生异常时,它会优先将异常委托给父协程区处理,以此类推直到根协程作用域或者顶级协程 。因此其永远不会使用我们子协程 CoroutineContext 传递的 CoroutineExceptionHandler

如下示例所示:

scss 复制代码
val scope = CoroutineScope(Job())
 scope.launch() {
     launch(CoroutineExceptionHandler { _, _ -> }) {
         delay(10)
         throw RuntimeException()
     }
 }

运行时,仍然会抛出异常。原因就是我们的 CoroutineExceptionHandler 位置不是根协程。

再看一个例子:

kotlin 复制代码
@OptIn(DelicateCoroutinesApi::class)
@JvmStatic
fun main(args: Array<String>) = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception")
    }
    val deferred = GlobalScope.async(handler) {
        throw ArithmeticException()
    }
    deferred.await()
}

这里我们使用了 async,它会直接抛出异常给开发者处理,而不是进行传递。所以这里会报错。

其根本原因在于,async 创建的协程类型是 DeferredCoroutine 类型,它不处理异常。而 launch 创建的是 StandaloneCoroutine 类型,它处理异常:

kotlin 复制代码
private open class StandaloneCoroutine(
    parentContext: CoroutineContext,
    active: Boolean
) : AbstractCoroutine<Unit>(parentContext, initParentJob = true, active = active) {
    override fun handleJobException(exception: Throwable): Boolean {
        handleCoroutineException(context, exception)
        return true
    }
}

可以看到,它复写了 handleJobException 方法,而DeferredCoroutine 没有。

父协程处理异常时机

只有当所有子运行程序都终止时,父运行程序才会处理异常,下面的例子就是证明:

scss 复制代码
@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception -> 
        println("CoroutineExceptionHandler got $exception") 
    }
    val job = GlobalScope.launch(handler) {
        launch { // the first child
            try {
                delay(Long.MAX_VALUE)
            } finally {
                withContext(NonCancellable) {
                    println("Children are cancelled, but exception is not handled until all children terminate")
                    delay(100)
                    println("The first child finished its non cancellable block")
                }
            }
        }
        launch { // the second child
            delay(10)
            println("Second child throws an exception")
            throw ArithmeticException()
        }
    }
    job.join()
}

第二个协程出现异常,父协程会取消其他子协程,当第一个子协程取消的时候,它进入 finally,我们在 finally 里面做了一个延迟操作。

父协程处理异常的时机,是在这个子协程延迟动作完成之后,故输出为:

sql 复制代码
Second child throws an exception
Children are cancelled, but exception is not handled until all children terminate
The first child finished its non cancellable block
CoroutineExceptionHandler got java.lang.ArithmeticException

异常合并

当一个协程的多个子协程出现异常失败时,一般的规则是 "第一个异常胜出",因此第一个异常会得到处理。在第一个异常之后发生的所有其他异常都会作为被抑制的异常附加到第一个异常上。

kotlin 复制代码
@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception with suppressed ${exception.suppressed.contentToString()}")
    }
    val job = GlobalScope.launch(handler) {
        launch {
            try {
                delay(Long.MAX_VALUE) // it gets cancelled when another sibling fails with IOException
            } finally {
                throw ArithmeticException() // the second exception
            }
        }
        launch {
            delay(100)
            throw IOException() // the first exception
        }
        delay(Long.MAX_VALUE)
    }
    job.join()  
}

输出为:

csharp 复制代码
CoroutineExceptionHandler got java.io.IOException with suppressed [java.lang.ArithmeticException]

但是 CancellationException 不会被附加上去,它是透明的。

Supervision job

假设我们在协程的作用域中定义了任务的用户界面组件。如果 UI 的任何子任务失败,并不一定需要取消(实际上是杀死)整个 UI 组件,但如果 UI 组件被销毁(其任务被取消),则有必要取消所有子任务。

这种情况下,我们就需要防止子协程被取消,那么怎么才能做到呢?

SupervisorJob 可用于上述目的。

scss 复制代码
fun main() = runBlocking {
    val supervisor = SupervisorJob()
    with(CoroutineScope(coroutineContext + supervisor)) {
        // launch the first child -- its exception is ignored for this example (don't do this in practice!)
        val firstChild = launch(CoroutineExceptionHandler { _, _ ->  }) {
            println("The first child is failing")
            throw AssertionError("The first child is cancelled")
        }
        // launch the second child
        val secondChild = launch {
            firstChild.join()
            // Cancellation of the first child is not propagated to the second child
            println("The first child is cancelled: ${firstChild.isCancelled}, but the second one is still active")
            try {
                delay(Long.MAX_VALUE)
            } finally {
                // But cancellation of the supervisor is propagated
                println("The second child is cancelled because the supervisor was cancelled")
            }
        }
        // wait until the first child fails & completes
        firstChild.join()
        println("Cancelling the supervisor")
        supervisor.cancel()
        secondChild.join()
    }
}

当 firstChild 协程出现异常时,它跑到了自己的 CoroutineExceptionHandler 里面去处理了。这是因为它从父协程那里继承了 SupervisorJob,所以它不会向上传播遗产。而且,firstChild 的失败不会影响 secondChild 的运行。

输出:

sql 复制代码
The first child is failing
The first child is cancelled: true, but the second one is still active
Cancelling the supervisor
The second child is cancelled because the supervisor was cancelled
相关推荐
Tiffany_Ho2 分钟前
【TypeScript】知识点梳理(三)
前端·typescript
安冬的码畜日常1 小时前
【D3.js in Action 3 精译_029】3.5 给 D3 条形图加注图表标签(上)
开发语言·前端·javascript·信息可视化·数据可视化·d3.js
小白学习日记2 小时前
【复习】HTML常用标签<table>
前端·html
丁总学Java2 小时前
微信小程序-npm支持-如何使用npm包
前端·微信小程序·npm·node.js
yanlele2 小时前
前瞻 - 盘点 ES2025 已经定稿的语法规范
前端·javascript·代码规范
懒羊羊大王呀3 小时前
CSS——属性值计算
前端·css
DOKE3 小时前
VSCode终端:提升命令行使用体验
前端
xgq3 小时前
使用File System Access API 直接读写本地文件
前端·javascript·面试
用户3157476081353 小时前
前端之路-了解原型和原型链
前端