全面指南与最佳实践
目录导读
协程异常的核心概念与挑战
1 什么是协程异常?
协程是一种轻量级线程,常用于异步编程,当协程内部发生未处理的错误(如网络超时、数据解析失败、资源耗尽)时,就会产生协程异常,与普通异常不同,协程异常可能发生在不同的执行上下文中,且可能被显式或隐式地延迟处理。

2 为什么捕获协程异常比普通异常更复杂?
- 异步传播:异常可能在
await、yield或resume时传播,而非立即抛出。 - 结构化并发:多个协程并行时,一个协程的失败可能影响其他协程。
- 上下文丢失:错误栈可能不完整,难以定位原始触发点。
- 静默失败:部分框架(如 Python asyncio 未捕获的任务)会悄悄忽略异常。
主流语言协程异常捕获机制
1 Python asyncio 异常捕获
基础模式:使用 try/except 包裹 await 调用。
import asyncio
async def risky_task():
raise ValueError("数据格式错误")
async def main():
try:
await risky_task()
except ValueError as e:
print(f"捕获异常: {e}")
asyncio.run(main())
高级技巧:
-
asyncio.gather()异常处理:设置return_exceptions=True可返回异常对象而非抛出。results = await asyncio.gather(task1(), task2(), return_exceptions=True) for res in results: if isinstance(res, Exception): handle_error(res) -
任务级异常捕获:使用
Task.add_done_callback()检测任务状态。task = asyncio.create_task(coro()) def callback(t): if t.exception(): print(f"任务异常: {t.exception()}") task.add_done_callback(callback) -
全局异常处理器:自定义
loop.set_exception_handler()捕获未处理异常。loop = asyncio.get_event_loop() loop.set_exception_handler(lambda loop, context: log_error(context))
2 JavaScript async/await 异常捕获
标准模式:使用 try/catch 包裹 await。
async function fetchData() {
const response = await fetch('https://example.com');
if (!response.ok) throw new Error('请求失败');
return response.json();
}
async function main() {
try {
const data = await fetchData();
console.log(data);
} catch (err) {
console.error('捕获异常:', err);
}
}
Promise 链式处理:Promise.catch() 可捕获未处理的异步异常。
fetchData()
.then(data => process(data))
.catch(err => console.error('链式捕获:', err));
浏览器端全局未处理异常:监听 window.onunhandledrejection 事件。
window.addEventListener('unhandledrejection', (event) => {
console.warn('未处理 Promise 拒绝:', event.reason);
});
3 Kotlin 协程异常捕获
Kotlin 提供了结构化并发,异常会沿作用域层级传播。
基础方式:try/catch 在协程体内部使用。
fun main() = runBlocking {
try {
launch {
delay(100)
throw RuntimeException("崩溃!")
}.join()
} catch (e: Exception) {
println("捕获: ${e.message}")
}
}
高级控制:
-
SupervisorJob:取消子协程不会导致父协程取消,适合独立任务。val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) scope.launch { // 子协程异常不会影响其他兄弟协程 } -
CoroutineExceptionHandler:全局捕获未处理异常。val handler = CoroutineExceptionHandler { _, exception -> println("全局捕获: $exception") } val scope = CoroutineScope(handler) -
async/await异常处理:Deferred.await()会重新抛出异常。val deferred = async { throw Exception("错误") } deferred.await() // 会抛出异常
4 Go Goroutine 异常处理
Go 的 goroutine 本身不提供内置捕获机制,需手动管理。
标准模式:使用 recover 在 defer 中捕获。
func safeGoroutine(f func()) {
defer func() {
if r := recover(); r != nil {
log.Printf("Goroutine 异常: %v", r)
}
}()
f() // 执行危险操作
}
高级模式:使用 errgroup 库(golang.org/x/sync/errgroup)结构化并发错误。
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error {
return doWork(ctx)
})
if err := g.Wait(); err != nil {
log.Fatal("子任务错误:", err)
}
常见陷阱与解决方案
陷阱1:忘记 await 或 await 位置错误
- 现象:异常被抛出但未被捕获,导致程序崩溃或静默失败。
- 解决方案:使用 lint 工具(如 Python
flake8-async)检查未await的协程。
陷阱2:在 finally 块中进行二次异步操作
- 问题:
finally中的await可能因原始异常而中断。 - 解决:使用
try-except-finally分阶段处理,或使用contextlib.suppress。
陷阱3:忽视任务取消(Cancellation)异常
- 影响:
asyncio.CancelledError或 Kotlin 的CancellationException若被普通except捕获,会导致任务无法正确取消。 - 最佳实践:特殊处理取消异常并重新抛出。
except asyncio.CancelledError:
# 清理资源
raise # 必须重新抛出以维持取消状态
实战:编写健壮的异常捕获脚本
以下是一个 Python 综合示例,展示如何捕获协程异常并避免常见陷阱。
import asyncio
import logging
logging.basicConfig(level=logging.INFO)
async def unreliable_service(session, url):
try:
async with session.get(url, timeout=5) as response:
if response.status != 200:
raise ValueError(f"服务返回状态码 {response.status}")
data = await response.json()
return data
except asyncio.CancelledError:
logging.warning("任务被取消,执行清理")
raise # 必须重新抛出
except Exception as e:
logging.error(f"请求异常: {type(e).__name__}: {e}")
raise # 可重新抛出或返回默认值
async def main():
import aiohttp
connector = aiohttp.TCPConnector(limit=10)
async with aiohttp.ClientSession(connector=connector) as session:
urls = ["https://api1.example.com", "https://api2.example.com"]
tasks = [unreliable_service(session, url) for url in urls]
# 使用 gather 并返回异常对象
results = await asyncio.gather(*tasks, return_exceptions=True)
for url, result in zip(urls, results):
if isinstance(result, Exception):
logging.warning(f"{url} 失败: {result}")
else:
logging.info(f"{url} 成功: {result}")
if __name__ == "__main__":
try:
asyncio.run(main())
except Exception as e:
logging.critical(f"主循环异常: {e}", exc_info=True)
关键设计:
- 使用
return_exceptions=True避免单个失败导致整体中断。 CancelledError被单独处理并重新抛出。- 顶层
try/except捕获向主循环逃逸的异常。 - 通过
aiohttp连接池管理并发限制。
问答环节
Q1:协程异常和普通异常在捕获方式上有何本质区别?
A:普通异常是同步的,调用栈清晰,协程异常可能跨多个事件循环循环周期,且需要 await 或 yield 才能传播,Python 中未 await 的异步生成器异常会丢失;Kotlin 协程异常默认传播到父协程。
Q2:是否应该总是捕获所有协程异常?
A:不,应遵循异常边界设计原则:
- 在任务内部捕获已知业务异常(如网络超时、数据验证错误)。
- 让系统级异常(如
CancelledError、内存错误)向上传播。 - 使用
global exception handler捕获未预期异常,记录日志而非掩盖。
Q3:在微服务架构中,如何统一处理跨服务的协程异常?
A:建议采用熔断模式(Circuit Breaker)结合协程超时:
- 使用库如
pybreaker(Python)或Hystrix(Java)包装远程调用。 - 配置超时和重试策略,异常时返回默认值或降级响应。
- 通过
OpenTelemetry将异常上下文传播到链路跟踪系统。 - 在 API 网关层统一捕获未处理异常,返回 5xx 响应和错误码。
Q4:如何调试难以复现的协程异常?
A:
- 开启调试日志:Python 设置
logging.getLogger('asyncio').setLevel(logging.DEBUG)。 - 使用
asyncio.Task.all_tasks()打印当前所有任务状态。 - 使用
traceback库在异常处理中打印完整栈帧。 - 在协程入口处添加唯一 ID(如 UUID),遍历日志关联错误。
协程异常捕获的核心在于理解异常的生命周期,从任务创建、执行到结果获取,每个阶段都需不同防护,通过结构化并发(如 TaskGroup、SupervisorJob)、精确的 try/catch 层级以及全局兜底机制,可以构建既健壮又易维护的异步系统。