
文章目录
- 开篇导读
- 一、超时处理全景
- 二、异步函数中的异常处理基础
- 三、任务(Task)的异常捕获
- 四、`asyncio.CancelledError` 深度解析
- 五、全局异常监听与未捕获异常处理
- 六、自定义异步异常
- 七、批量任务异常统一处理实战
- 八、生产级错误处理规范
- 九、常见报错原因解析与修复方案
- 9.1 `asyncio.TimeoutError` 未捕获导致程序退出
- 9.2 `Task was destroyed but it is pending!`
- 9.3 `RuntimeError: Event loop is closed`
- 9.4 `CancelledError` 被 `except Exception` 捕获且未重抛
- 9.5 `asyncio.run() cannot be called from a running event loop`
- 9.6 协程中使用了`time.sleep` 导致超时失效
- 9.7 `Future attached to a different loop`
- 9.8 未捕获的异常导致`gather`提前取消其他任务
- 9.9 `shield` 误解导致任务仍在后台运行
- 十、实战案例:健壮的异步爬虫
- 十一、知识点总结
- 十二、课后实操题
- 参考答案提示
- 🔗《20节课 asyncio 从入门到精通》系列课程导航
开篇导读
学习目标
完成本节课后,你将能够:
- 掌握asyncio中多种超时控制手段(
wait_for、wait超时、shield保护、整体超时) - 熟练使用
try/except/finally在异步函数中捕获和处理异常 - 理解Task异常的不同传播方式,学会捕获
asyncio.CancelledError - 实现全局异常监听与未捕获异常处理钩子
- 自定义异步异常类,构建清晰的错误体系
- 对批量任务进行统一的异常处理与降级
- 识别并修复asyncio开发中最常见的15+种错误
适用人群
- 编写的异步程序经常因超时或异常导致不可预期行为
- 需要构建高可用的异步服务,确保单个任务失败不会影响整体
- 希望建立生产级的错误处理规范,提升代码健壮性
- 被
asyncio.TimeoutError、CancelledError、Task exception was never retrieved等报错困扰
学完收益
- 能够自信地为异步任务添加超时保护,防止无限等待
- 优雅地处理任务取消,释放资源不泄漏
- 构建全局异常捕获机制,记录所有未处理异常
- 写出符合生产标准的健壮异步代码,减少线上故障
一、超时处理全景
超时是异步编程中最常见的保护机制。asyncio提供了多种超时工具,适用于不同粒度。
1.1 asyncio.wait_for — 单任务超时
核心作用:为单个可等待对象(协程、Task、Future)添加超时限制,超时自动取消该任务。
result = await asyncio.wait_for(coro(), timeout=5.0)
行为详解:
- 在
timeout秒内若coro()未完成,则抛出asyncio.TimeoutError。 - 同时会取消被等待的任务(向协程内注入
CancelledError)。 - 如果任务在超时前完成,正常返回结果。
重要:被取消的任务必须正确处理CancelledError,否则可能导致资源未释放。
async def fetch_data():
try:
return await some_io()
except asyncio.CancelledError:
# 执行清理(关闭连接、释放锁等)
await cleanup()
raise # 必须重新抛出,否则任务会变为“已完成”而非“已取消”
async def main():
try:
result = await asyncio.wait_for(fetch_data(), timeout=1.0)
except asyncio.TimeoutError:
print("超时,fetch_data已被取消")
参数细节:
timeout可以是None,表示无限等待。- 若
timeout<=0,则立即超时。
1.2 asyncio.wait 超时 — 批量任务超时且不自动取消
done, pending = await asyncio.wait(tasks, timeout=3.0)
- 超时不会取消未完成任务,它们仍在后台运行。
- 返回的
done包含超时前已完成的任务,pending包含未完成的。 - 需要手动处理
pending(取消或后续等待)。
适用场景:
- 需要部分结果,且超时后还想继续处理未完成的任务(例如记录日志后继续等待)。
- 不想因为超时强制取消某些重要任务。
done, pending = await asyncio.wait(tasks, timeout=2)
for task in done:
result = task.result()
# 使用结果
# 对 pending 可以选择取消或继续等待
if pending:
print(f"还有{len(pending)}个任务未完成,等待它们")
await asyncio.wait(pending) # 无限等待剩余任务
1.3 asyncio.timeout 上下文管理器(Python 3.11+)
Python 3.11 引入了更优雅的超时语法:asyncio.timeout(delay)。
async def main():
try:
async with asyncio.timeout(5):
await long_operation()
await another_operation()
except TimeoutError:
print("整个代码块超时")
- 上下文管理器内的所有操作共享同一个超时限制。
- 超时抛出
TimeoutError(内置异常,不需要asyncio.前缀)。 - 支持
timeout为None表示无超时。 - 可配合
asyncio.timeout_at(deadline)使用绝对时间。
注意:3.11之前可以手动模拟,或用第三方库async-timeout。
1.4 整体超时与嵌套超时
async def main():
# 整体超时5秒
try:
async with asyncio.timeout(5):
# 内部每个任务也有个体超时2秒
tasks = [asyncio.wait_for(worker(i), timeout=2) for i in range(10)]
results = await asyncio.gather(*tasks, return_exceptions=True)
except TimeoutError:
print("整体超时,所有子任务会被自动取消")
规则:
- 嵌套超时时,内层超时先触发会取消单个任务;外层超时触发会取消包含的所有任务。
- 任务的取消会传播给内层的
wait_for,依次抛CancelledError。
1.5 永久等待的保护 — shield 取消保护
asyncio.shield 可以保护一个可等待对象不被取消(但仍会超时?注意语义)。
protected = await asyncio.shield(coro())
shield包装的任务会响应取消,但如果外部取消了shield本身,取消信号不会传递到内部任务。然而,如果shield所在的父任务被取消,它自身会进入取消状态,但内部任务仍继续运行(除非它自己也设置了取消点)。- 实际使用场景较少,常用在确保关键清理操作不被中断。
async def critical_cleanup():
# 这个清理不希望被外界取消
await asyncio.sleep(1)
print("清理完成")
async def main():
task = asyncio.create_task(critical_cleanup())
await asyncio.shield(task) # 即使 main 被取消,critical_cleanup 仍会继续
1.6 自定义超时装饰器
def timeout(seconds):
def decorator(func):
async def wrapper(*args, **kwargs):
return await asyncio.wait_for(func(*args, **kwargs), timeout=seconds)
return wrapper
return decorator
@timeout(3)
async def slow_operation():
await asyncio.sleep(5)
return "done"
二、异步函数中的异常处理基础
2.1 try/except/finally 在协程中的使用
与同步函数几乎相同,差异在于await可能抛出异常,且CancelledError需要特殊处理。
async def safe_call():
try:
await risky_io()
except ConnectionError:
return "fallback"
except asyncio.CancelledError:
print("被取消,执行清理")
raise
finally:
await release_resources() # finally 中也可以使用 await
注意:finally块中如果包含await,需要确保该操作不会抛出未被捕获的异常,否则会覆盖原始异常。
2.2 多个await的异常处理
async def multiple():
try:
await step1()
await step2() # 如果 step1 抛出,step2 不会执行
except Exception:
# 统一处理
pass
如果需要step1失败后仍执行step2(例如清理),使用单独try块。
2.3 捕获特定异常与异常链
try:
await process()
except ValueError as e:
raise RuntimeError("处理失败") from e
2.4 协程内部异常与外部捕获
调用方await一个协程时,协程内部抛出的未处理异常会传播到调用方。
async def inner():
raise ValueError("inner error")
async def outer():
try:
await inner()
except ValueError as e:
print(f"捕获: {e}") # 能够捕获
三、任务(Task)的异常捕获
3.1 create_task 后不等待的异常丢失
async def buggy():
raise RuntimeError("oops")
async def main():
asyncio.create_task(buggy())
await asyncio.sleep(1) # 异常永远不会被抛出,控制台可能打印 "Task exception was never retrieved"
解决方案:
- 保存任务引用,并添加回调或显式
await。 - 使用
asyncio.gather或asyncio.wait管理任务。
task = asyncio.create_task(buggy())
# 方式1:添加回调
task.add_done_callback(lambda t: t.exception()) # 触发异常提取
# 方式2:在适当的地方 await
await task # 会抛出异常
3.2 使用task.exception()获取异常
task = asyncio.create_task(buggy())
await asyncio.sleep(0.1) # 等待任务完成
if task.done() and not task.cancelled():
exc = task.exception()
if exc:
print(f"任务异常: {exc}")
3.3 批量任务异常收集
tasks = [asyncio.create_task(some_coro(i)) for i in range(10)]
done, _ = await asyncio.wait(tasks)
for t in done:
try:
result = t.result()
except Exception as e:
print(f"任务失败: {e}")
# 记录错误或降级
更好的方式是使用gather(return_exceptions=True)。
四、asyncio.CancelledError 深度解析
4.1 何时抛出
- 显式调用
task.cancel() wait_for超时gather中某任务失败且return_exceptions=False时,其他任务收到取消- 事件循环关闭时清除未完成任务
4.2 捕获与重抛规则
async def cancellable():
try:
await long_io()
except asyncio.CancelledError:
print("清理资源")
# 注意:必须重新抛出,否则任务会转为"完成"状态,取消被吞没
raise
绝不:捕获CancelledError后不重新抛出且不检查取消标志,会导致任务永远不取消。
4.3 检查取消状态
if asyncio.current_task().cancelled():
# 执行清理后返回或抛出
raise asyncio.CancelledError
4.4 抑制取消(临时)
可以通过try/finally 或 shield 临时阻止取消传播,但一般不推荐。
五、全局异常监听与未捕获异常处理
5.1 事件循环的异常处理器
def custom_exception_handler(loop, context):
# context 包含:
# - 'message': 错误消息
# - 'exception': 异常对象(如果有)
# - 'future': 出错的Future/Task
# - 'handle': 回调句柄等
print(f"捕获到未处理异常: {context['message']}")
exc = context.get('exception')
if exc:
print(f"异常详情: {exc}")
loop = asyncio.get_running_loop()
loop.set_exception_handler(custom_exception_handler)
- 全局处理器会捕获:
- 回调(
call_soon、call_later)中未捕获的异常 - Task中未被任何
await或回调处理的异常(即"task exception was never retrieved") - 服务器协议中的未处理异常
- 回调(
设置时机:在run之前或在协程最早处。
5.2 为特定Task设置回调
def on_task_done(task):
try:
task.result()
except Exception as e:
# 记录异常,避免丢失
log.error(f"Task {task.get_name()} failed: {e}")
task.add_done_callback(on_task_done)
5.3 使用asyncio.run时的默认异常处理
asyncio.run 会在事件循环关闭前输出所有未处理异常到sys.stderr,但不会使程序退出。最好显式设置处理器。
六、自定义异步异常
6.1 定义异步异常类(与同步相同)
class AsyncTimeoutError(asyncio.TimeoutError):
"""自定义超时异常"""
pass
class RetryableError(Exception):
"""可重试错误"""
pass
6.2 在协程中抛出
async def validate(data):
if not data:
raise AsyncTimeoutError("无效数据")
6.3 异常层次设计
建议:
- 基础异常:
AppError - 可恢复异常:
RetryableError、RateLimitError - 不可恢复:
FatalError(应导致程序退出)
七、批量任务异常统一处理实战
7.1 使用 return_exceptions=True 实现优雅降级
results = await asyncio.gather(
fetch("url1"),
fetch("url2"),
fetch("url3"),
return_exceptions=True
)
success_data = []
errors = []
for res in results:
if isinstance(res, Exception):
errors.append(res)
else:
success_data.append(res)
# 部分成功可继续,记录errors
7.2 带重试的批量处理
async def fetch_with_retry(url, retries=3):
for attempt in range(retries):
try:
return await fetch(url)
except Exception as e:
if attempt == retries - 1:
raise
await asyncio.sleep(2 ** attempt) # 指数退避
7.3 熔断器模式(简单实现)
class CircuitBreaker:
def __init__(self, fail_threshold=5, recovery_timeout=30):
self.fail_count = 0
self.threshold = fail_threshold
self.timeout = recovery_timeout
self.last_fail_time = 0
async def call(self, coro):
if self.fail_count >= self.threshold:
if time.monotonic() - self.last_fail_time > self.timeout:
self.fail_count = 0 # 重置
else:
raise Exception("熔断器打开,拒绝调用")
try:
result = await coro
self.fail_count = 0
return result
except Exception as e:
self.fail_count += 1
self.last_fail_time = time.monotonic()
raise
八、生产级错误处理规范
8.1 日志记录
- 所有异常在捕获后应记录详细上下文(任务名、参数、堆栈)。
- 使用结构化日志(如
structlog)便于检索。
import logging
logger = logging.getLogger(__name__)
try:
await operation()
except Exception as e:
logger.exception("操作失败", extra={"task": asyncio.current_task().get_name()})
raise
8.2 异常与返回值的平衡
- 对于业务可接受的失败,返回特殊值或
None比抛异常更清晰。 - 对于不应该发生的错误,抛异常。
8.3 清理资源的安全模式
resource = await acquire()
try:
await use(resource)
finally:
await release(resource) # 即使 use 抛异常也会释放
8.4 优雅关闭时的异常处理
async def shutdown(loop):
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
loop.stop()
九、常见报错原因解析与修复方案
9.1 asyncio.TimeoutError 未捕获导致程序退出
现象:Task exception was never retrieved 或程序崩溃。
修复:使用try/except包裹wait_for。
9.2 Task was destroyed but it is pending!
原因:事件循环关闭时仍有未完成的任务。
修复:在关闭循环前取消所有任务(见8.4)。
9.3 RuntimeError: Event loop is closed
原因:在循环关闭后尝试调用get_event_loop或提交任务。
修复:避免使用已关闭的循环,使用asyncio.run管理或每次重新创建。
9.4 CancelledError 被 except Exception 捕获且未重抛
现象:任务取消无效,代码挂起。
修复:单独捕获CancelledError并重抛。
9.5 asyncio.run() cannot be called from a running event loop
原因:在协程内部或已有循环的线程中调用asyncio.run。
修复:使用await或create_task。
9.6 协程中使用了time.sleep 导致超时失效
原因:阻塞事件循环,超时检测无法运行。
修复:替换为await asyncio.sleep。
9.7 Future attached to a different loop
原因:在不同事件循环中创建的Future被混用(例如跨线程)。
修复:确保Future与循环匹配,使用loop.create_future()。
9.8 未捕获的异常导致gather提前取消其他任务
现象:一个任务失败,其他任务莫名其妙被取消。
修复:若期望部分失败不影响其他,设置return_exceptions=True。
9.9 shield 误解导致任务仍在后台运行
现象:使用shield后认为任务被保护,但外部超时仍会抛出到shield表面。
修复:仔细阅读shield语义,仅保护内部不被取消,外层仍会等待并可能超时。
十、实战案例:健壮的异步爬虫
综合运用超时、重试、异常处理。
import asyncio
import aiohttp
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
async def fetch_url(session, url, timeout=5):
try:
async with session.get(url, timeout=timeout) as resp:
if resp.status == 200:
return await resp.text()
else:
raise ValueError(f"HTTP {resp.status}")
except asyncio.TimeoutError:
raise TimeoutError(f"Timeout fetching {url}")
except Exception as e:
raise RuntimeError(f"Failed to fetch {url}") from e
async def fetch_with_retry(session, url, max_retries=2):
for attempt in range(max_retries):
try:
return await fetch_url(session, url)
except (TimeoutError, ValueError) as e:
if attempt == max_retries - 1:
raise
wait = 1 * (2 ** attempt)
logger.warning(f"Retry {url} after {wait}s, error: {e}")
await asyncio.sleep(wait)
async def main():
urls = ["https://example.com", "https://nonexistent.xxx", "https://httpbin.org/delay/10"]
async with aiohttp.ClientSession() as session:
tasks = [asyncio.create_task(fetch_with_retry(session, url)) for url in urls]
# 整体超时15秒
try:
results = await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=15)
except asyncio.TimeoutError:
logger.error("整体超时")
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
return
for url, result in zip(urls, results):
if isinstance(result, Exception):
logger.error(f"{url} 失败: {result}")
else:
logger.info(f"{url} 成功, 长度 {len(result)}")
if __name__ == "__main__":
asyncio.run(main())
十一、知识点总结
超时工具箱
| 工具 | 粒度 | 取消行为 | 推荐场景 |
|---|---|---|---|
wait_for | 单任务 | 自动取消 | 任何单个异步操作需要超时 |
wait(timeout) | 多任务 | 不取消(返回pending) | 需要超时后处理未完成任务 |
timeout 上下文 | 代码块 | 块内所有任务取消 | 一组操作合并超时(3.11+) |
shield | 保护任务 | 取消不传播 | 保护关键清理操作 |
异常处理层次
- 协程内:
try/except捕获常规异常,单独处理CancelledError并重抛。 - 任务层:使用
add_done_callback或task.exception()捕获未等待任务的异常。 - 批量任务:
gather(return_exceptions=True)收集全部异常。 - 全局:设置
loop.set_exception_handler兜底。
避坑清单
- 永远不要吞没
CancelledError。 - 在
finally块中的await要谨慎。 - 生产环境务必设置全局异常处理器并记录日志。
- 超时时间设置要根据实际场景调优(避免过大或过小)。
- 任务取消需确保资源释放。
十二、课后实操题
题目1:超时嵌套与取消传播
编写一个协程async def nested_timeout(),内部有两层超时:外层timeout=3,内层timeout=1。内层执行一个asyncio.sleep(2)。预测会抛出什么异常?写出代码验证并解释为什么。
题目2:实现安全的 gather_with_timeout
实现函数 async def gather_with_timeout(coros, timeout),返回结果列表。要求:
- 整体超时
timeout秒,超时后取消所有未完成子任务。 - 如果超时发生,抛出自定义异常
PartialTimeoutError,并附带已完成的结果列表和未完成的任务数量。 - 如果子任务中有异常,正常传播(除非超时发生)。
题目3:全局异常处理器记录堆栈
编写一个事件循环异常处理器,将错误信息输出到文件,包含任务名称和堆栈。然后故意创建一个未捕获异常的后台任务,观察处理器是否触发。
题目4:重试装饰器
实现一个装饰器@async_retry(max_attempts=3, delay=1, backoff=2, exceptions=(Exception,)),用于异步函数。在函数抛出指定异常时自动重试,重试间隔逐次增加。若最终失败则抛出最后一次异常。
题目5:熔断器集成
将第7.3节的熔断器类扩展为可配置的异步上下文管理器,并用它包装一个模拟的远端调用(随机失败)。在main中循环调用,观察熔断器打开后的行为,并实现自动恢复。
参考答案提示
题目1
async def nested_timeout():
try:
async with asyncio.timeout(3): # 外层
try:
await asyncio.wait_for(asyncio.sleep(2), timeout=1) # 内层1秒
except asyncio.TimeoutError:
print("内层超时")
raise
except asyncio.TimeoutError:
print("外层超时")
实际会先抛内层TimeoutError,被内层捕获打印后重抛,被外层捕获打印“外层超时”。注意内层超时后sleep(2)的任务被取消,不会再运行到2秒。
题目2(核心代码)
class PartialTimeoutError(Exception):
def __init__(self, results, pending_count):
self.results = results
self.pending_count = pending_count
async def gather_with_timeout(coros, timeout):
task = asyncio.create_task(asyncio.gather(*coros, return_exceptions=False))
try:
return await asyncio.wait_for(task, timeout=timeout)
except asyncio.TimeoutError:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# 收集已完成的结果(从coros中无法直接获取,可以用原gather的return_exceptions实现部分收集,此处略)
raise PartialTimeoutError([], len(coros))
题目3
def handler(loop, context):
with open("error.log", "a") as f:
f.write(f"Message: {context['message']}\n")
exc = context.get('exception')
if exc:
traceback.print_exception(type(exc), exc, exc.__traceback__, file=f)
task = context.get('task')
if task:
f.write(f"Task name: {task.get_name()}\n")
loop = asyncio.get_running_loop()
loop.set_exception_handler(handler)
asyncio.create_task(buggy())
await asyncio.sleep(0.1)
结语:超时与异常处理是生产级异步代码不可或缺的部分。通过本课的学习,你应该能够从容地为自己的异步程序添加可靠的安全网。下一课将进入asyncio同步原语,学习如何在协程间安全地共享状态。
🔗《20节课 asyncio 从入门到精通》系列课程导航
🌟 感谢您耐心阅读到这里!
💡 如果本文对您有所启发欢迎:
👍 点赞📌 收藏 📤 分享给更多需要的伙伴。
🗣️ 期待在评论区看到您的想法, 共同进步。
🔔 关注我,持续获取更多干货内容~
🤗 我们下篇文章见~
933

被折叠的 条评论
为什么被折叠?



