协程06 - 异常处理

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

  • ​一种是自动传播( launch 或 actor)

  • 一种是向用户暴露该异常( async 或 produce )

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

CoroutineExceptionHandler

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

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

如下示例所示:

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

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

再看一个例子:

@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 类型,它处理异常:

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 没有。

父协程处理异常时机

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

@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 里面做了一个延迟操作。

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

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

异常合并

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

@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()  
}

输出为:

CoroutineExceptionHandler got java.io.IOException with suppressed [java.lang.ArithmeticException]

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

Supervision job

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

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

SupervisorJob 可用于上述目的。

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 的运行。

输出:

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
<think> 我们正在讨论的是Python asyncio中单个协程异常处理问题。根据用户的问题,他特别询问了“单个协程出现异常的处理方法”。结合之前提供的引用信息,虽然引用中主要讨论的是Java中的线程异常处理,但我们可以借鉴其思想,应用到Python asyncio的协程异常处理中。 我们需要介绍在单个协程中处理异常的几种方式: 1. 使用try-except块直接捕获协程内的异常(最直接的方式) 2. 在await调用时捕获异常(因为await会传播协程的异常) 3. 通过Task对象获取异常(当协程被包装成任务时) 但是用户特别强调是“单个协程”,所以重点应放在前两种方法,因为单个协程通常直接await,而不需要包装成任务。 另外,引用[3]中展示了Java中捕获数组越界和空指针异常的例子,我们可以用类似的结构来展示Python协程的异常捕获。 步骤: 1. 定义一个会抛出异常的协程函数 2. 在调用这个协程的地方使用try-except捕获异常 3. 展示捕获特定异常和通用异常的方法 注意:由于协程本身是异步的,但是异常会在await点抛出,所以捕获异常的位置就是await语句所在的位置。 我们将编写示例代码展示如何处理单个协程中的异常。 </think> 在 Python asyncio 中处理单个协程异常的方法如下,这些方法确保异步程序即使出错也能保持稳定: ### 🔧 异常处理核心方法 1. **`try-except` 直接捕获**(最推荐) ```python async def main(): try: result = await risky_coroutine() # 可能抛出异常的协程 except ValueError as e: # 捕获特定异常 print(f"值错误: {e}") except Exception as e: # 通用异常捕获 print(f"未知错误: {e}") finally: # 确保执行清理操作 print("清理资源") ``` 2. **通过 `Task` 对象获取异常**(协程被包装为任务时) ```python async def main(): task = asyncio.create_task(risky_coroutine()) try: result = await task except Exception as e: # 可直接访问任务的异常信息 print(f"任务抛出的异常: {task.exception()}") ``` 3. **全局异常处理**(适用于所有未捕获异常) ```python def handle_exception(loop, context): print(f"全局捕获异常: {context['exception']}") async def main(): loop = asyncio.get_running_loop() loop.set_exception_handler(handle_exception) await risky_coroutine() # 异常会被全局处理器捕获 ``` ### 📊 异常处理对比表 | 方法 | 适用场景 | 优点 | 缺点 | |------|----------|------|------| | `try-except` | 精确捕获预期异常 | ✅ 精确控制<br>✅ 资源清理方便 | ❌ 需预知异常类型 | | 任务对象 | 任务链路中的异常 | ✅ 获取完整异常堆栈<br>✅ 支持取消操作 | ❌ 需显式创建任务 | | 全局处理 | 未捕获的异常 | ✅ 防止程序崩溃<br>✅ 兜底保护 | ❌ 无法恢复执行流程 | ### ️ 关键注意事项 1. **异常传播特性**:协程中的异常会传播到 `await` 调用点[^1] ```python async def child(): raise ValueError("子协程错误") async def parent(): try: await child() # 异常在此处被捕获 except ValueError: print("捕获子协程异常") ``` 2. **`finally` 块的重要性**:确保资源释放(如网络连接、文件句柄) ```python async def db_query(): try: conn = await connect_db() return await conn.execute("SELECT...") finally: await conn.close() # 总是执行 ``` 3. **常见异步异常类型**: - `asyncio.TimeoutError`:异步操作超时 - `asyncio.CancelledError`:任务被取消 - `ConnectionError`:网络连接故障 ### 💡 最佳实践建议 1. **分层捕获**:先捕获具体异常,最后用 `Exception` 兜底 2. **日志记录**:使用 `logging.exception(e)` 记录完整堆栈 3. **异常封装**:将底层异常包装为业务异常 ```python async def api_call(): try: return await fetch_data() except IOError as e: raise ServiceUnavailable("服务不可用") from e ``` 4. **超时控制**:避免无限期阻塞 ```python try: await asyncio.wait_for(risky_call(), timeout=5.0) except asyncio.TimeoutError: print("操作超时") ``` > **提示**:协程异常处理逻辑与同步代码相似,但需注意 `await` 点是异常传播的关键节点。对于简单协程,直接使用 `try-except` 是最清晰的方式[^3]。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

二手的程序员

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值