第7课:asyncio|超时处理|异常捕获与错误处理全套方案

在这里插入图片描述

文章目录


开篇导读

学习目标

完成本节课后,你将能够:

  • 掌握asyncio中多种超时控制手段(wait_forwait超时、shield保护、整体超时)
  • 熟练使用try/except/finally在异步函数中捕获和处理异常
  • 理解Task异常的不同传播方式,学会捕获asyncio.CancelledError
  • 实现全局异常监听与未捕获异常处理钩子
  • 自定义异步异常类,构建清晰的错误体系
  • 对批量任务进行统一的异常处理与降级
  • 识别并修复asyncio开发中最常见的15+种错误

适用人群

  • 编写的异步程序经常因超时或异常导致不可预期行为
  • 需要构建高可用的异步服务,确保单个任务失败不会影响整体
  • 希望建立生产级的错误处理规范,提升代码健壮性
  • asyncio.TimeoutErrorCancelledErrorTask 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.前缀)。
  • 支持timeoutNone表示无超时。
  • 可配合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.gatherasyncio.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/finallyshield 临时阻止取消传播,但一般不推荐。


五、全局异常监听与未捕获异常处理

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_sooncall_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
  • 可恢复异常:RetryableErrorRateLimitError
  • 不可恢复: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 CancelledErrorexcept Exception 捕获且未重抛

现象:任务取消无效,代码挂起。

修复:单独捕获CancelledError并重抛。

9.5 asyncio.run() cannot be called from a running event loop

原因:在协程内部或已有循环的线程中调用asyncio.run

修复:使用awaitcreate_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保护任务取消不传播保护关键清理操作

异常处理层次

  1. 协程内try/except 捕获常规异常,单独处理CancelledError并重抛。
  2. 任务层:使用add_done_callbacktask.exception() 捕获未等待任务的异常。
  3. 批量任务gather(return_exceptions=True) 收集全部异常。
  4. 全局:设置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 从入门到精通》系列课程导航

去订阅

🌟 感谢您耐心阅读到这里!
💡 如果本文对您有所启发欢迎:
👍 点赞📌 收藏 📤 分享给更多需要的伙伴。
🗣️ 期待在评论区看到您的想法, 共同进步。
🔔 关注我,持续获取更多干货内容~
🤗 我们下篇文章见~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Thomas.Sir

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

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

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

打赏作者

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

抵扣说明:

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

余额充值