Python asyncio 超时控制与任务取消的正确姿势

asyncio 里"超时"和"取消"是一件事的两面:超时本质上是到点发取消信号。但很多代码只包了 wait_for 就以为万事大吉,真正的问题往往出在被取消的任务不肯干净退出。 最基础的写法 async def fetch(session,

asyncio 里”超时”和”取消”是一件事的两面:超时本质上是到点发取消信号。但很多代码只包了 wait_for 就以为万事大吉,真正的问题往往出在被取消的任务不肯干净退出。

最基础的写法

async def fetch(session, url):
    try:
        async with asyncio.timeout(5):          # Python 3.11+
            async with session.get(url) as resp:
                return await resp.text()
    except TimeoutError:
        return None

asyncio.timeout() 是 3.11 推荐的方式,比 wait_for 更轻——它不会每次调用都新建 Task。

为什么 wait_for 有隐患

# 不推荐:wait_for 会取消并等待内部任务清理
await asyncio.wait_for(slow_task(), timeout=5)

wait_for 在超时会取消内层任务,但如果内层任务吞掉了 CancelledError,wait_for 会一直等到它真正结束,超时就形同虚设。

吞掉 CancelledError 的典型反模式

async def worker():
    while True:
        try:
            await do_io()
        except Exception:
            pass          # 错误:把 CancelledError 也吞了

CancelledError 在 3.8 之后继承自 BaseException,except Exception 抓不到它,这一点反而安全。但要小心这种写法:

except BaseException:
    log.error("出错了")
    return None        # 错误:取消被当成了错误,循环无法退出

一旦任务被取消后没有重新抛出,asyncio 会认为它”拒绝取消”,整个关闭流程可能卡住。

正确的取消处理

async def worker():
    try:
        while True:
            await do_io()
    except asyncio.CancelledError:
        await cleanup()        # 释放资源
        raise                  # 必须重新抛出

raise 那一行是关键:处理完清理工作后,把取消信号继续往上传。

批量任务的超时

抓 100 个 URL,要求整体 10 秒内完成:

async with asyncio.TaskGroup() as tg:
    for url in urls:
        tg.create_task(fetch(session, url))

TaskGroup 配合 asyncio.timeout() 会在超时时取消组内所有任务,并等它们清理完才抛异常,比手动管理 gather 更可靠。

小结

  • 超时优先用 asyncio.timeout(),不要每次新建 Task
  • 捕获 CancelledError 后,清理完一定要重新 raise
  • 批量任务用 TaskGroup,别自己拼 gather + 异常判断