协程06 - 异常处理

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

  • 一种是自动传播( launch 或 actor)
  • 一种是向用户暴露该异常( async 或 produce )

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

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
相关推荐
子兮曰5 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
子兮曰5 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
前端小万5 天前
写公众号赚了 3000 块后,我做了一款叫 "一键成稿" 的软件
前端·微信小程序
爱勇宝5 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
三十而立洋5 天前
Cookie 详解:从产生到安全,一次讲透
前端·javascript
卡布鲁5 天前
把一个 Vite + Vue3 应用塞进 qiankun (React + Umi3) 主站:十个坑的复盘
前端·javascript·react.js
汉堡大王95275 天前
Jev:不是聊天机器人, 而是一个智能 if 语句
前端·人工智能·后端
梦想很大很大5 天前
从运行事实到回归证据:Workrun 的 Telemetry 与 Evaluation 实践
前端·人工智能·后端
计算机魔术师5 天前
Meta Muse agent 接入 Shopify 的 Shop Pay 实现代理式购物
前端
沙蒿同学5 天前
我用 Go 搭了一条 AI Agent 流水线:从 1 张商品图到一整套淘宝详情页
前端·javascript·后端