bigywater.tech
爬虫工程 · 23

asyncio 里的取消与超时:wait_for 与 asyncio.timeout 的差别

给爬虫的请求设超时看起来简单,但 asyncio 的取消是协作式的,不同写法的行为差别不小。这篇用本机实验把 wait_for、asyncio.timeout、shield、gather、TaskGroup 的行为逐一量出来,并看看取消一个进行中的 HTTP 请求对连接的影响。

1202 ms
协程吞掉 CancelledError 时,wait_for(…, 0.2) 实际返回的时间,并且没有抛 TimeoutError
20 对 2
gather 与 TaskGroup 在一个任务失败 0.7 秒后,其它任务的计数(gather 里它们还在跑)
2
一个请求被超时取消后,第 4 个请求累计用到的连接数(正常复用时是 1)

两种写法与嵌套

实测 · 本机执行输出
  asyncio.TimeoutError is TimeoutError:True(Python 3.14.8)
  wait_for(work(1.0), 0.2):TimeoutError,耗时 201 ms,被取消的协程日志 ['收到取消']
  async with asyncio.timeout(0.2):TimeoutError,耗时 200 ms,日志 ['收到取消']
  外层 timeout(0.3) 包着内层 timeout(1.0):TimeoutError,耗时 301 ms(外层先到期)
  timeout(1.0) 内部在 0.1 秒时用 reschedule 改成再过 0.2 秒到期:TimeoutError,耗时 301 ms
  没超时的 async with:cm.expired() = False
  超时的 async with:TimeoutError 抛出,cm.expired() = True

wait_for 和 asyncio.timeout 在时间上等价,都抛内置的 TimeoutError。asyncio.timeout 是上下文管理器,能嵌套,先到期的先触发,还能用 reschedule 在运行中修改到期时间,适合做“整个流程的总期限”。

取消是协作式的

实测 · 本机执行输出
  wait_for(swallower(), 0.2) 返回 'swallower 结束',耗时 1202 ms(0.2 秒到期,但协程又睡了 1 秒才结束)
  finally 里还有 0.1 秒的清理:wait_for(…, 0.2) 抛 TimeoutError,耗时 302 ms(等清理做完才抛)

超时通过向任务投递 CancelledError 实现。如果任务捕获并吞掉了它,超时就拦不住:实测 wait_for(…, 0.2) 在 1202 ms 后正常返回结果。finally 里如果还有 await,超时的抛出也要等清理做完(302 ms 而不是 200 ms)。

shield

实测 · 本机执行输出
  wait_for(shield(task), 0.1):101 ms 时超时,task.done()=False
  再过 0.6 秒:task.done()=True,task.result()=42,done 日志 ['job 完成了'](任务没被取消,结果还能拿到)
  对照:wait_for(task, 0.1)(没有 shield):task.cancelled()=True,done 日志 []

asyncio.shield 让超时只取消外层的包装,里面的任务继续跑完,结果还能拿到。代价是超时之后它仍在后台消耗资源,爬虫里要小心这种“幽灵请求”。

gather 与 TaskGroup

实测 · 本机执行输出
  gather 抛出 ValueError 的时刻:两个 ticker 已计数 2
  再过 0.7 秒,两个 ticker 在后台继续跑完:计数 20(共 20)——gather 抛异常时并没有取消其它任务
  TaskGroup 抛出 ExceptionGroup(含 ['ValueError']),此刻 ticker 计数 2
  再过 0.7 秒:计数 2(其它任务已被取消,不再增长)

asyncio.gather 在一个任务抛异常时直接把异常抛给调用者,其它任务仍在后台继续(实测计数一直走到 20)。TaskGroup 会取消其它任务,并把异常打包成 ExceptionGroup(用 except* 接)。

取消请求与连接

实测 · 本机执行输出
  连续 2 个正常请求累计连接数 1 → 1(复用);第 3 个请求在 0.1 秒被 asyncio.timeout 取消,之后第 4 个请求累计连接数 2(被取消的请求用的连接被关闭,下一个请求要新建)

取消一个正在进行的 aiohttp 请求后,它占用的那条连接会被关闭,下一个请求要新建连接(实测累计连接数从 1 变成 2)。这又把第 2 篇讲的建连接成本带回来。

分步演示

取消与超时
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / requests 2.34.2 / aiohttp 3.14.3 / lxml 6.1.1(系统 Python);所有请求只发给本机自己起的测试服务器(127.0.0.1,端口 9960 以上,不访问任何真实网站);服务器可以按实验需要制造延迟、断连、压缩、重定向等行为,并记录收到的原始请求;计时用 time.perf_counter,数字每次运行会有小幅抖动。Python 3.14.8 的 asyncio;各小实验用 asyncio.sleep 模拟工作;计数用后台 ticker 每 50 ms 加一;最后一节用 aiohttp 3.14.3 访问每个请求停 0.3 秒的本机服务器,连接数由服务器记录;每项各跑一次,毫秒数仅供参考
    • 没有验证:Python 3.11 及更早版本里 wait_for 的不同实现(那时它与 timeout 的行为有差异);asyncio.timeout 与 uncancel() 的配合;嵌套 TaskGroup 的取消传播;aiohttp 自己的 ClientTimeout 各项(connect、sock_read 等)取消时的行为;取消发生在响应读取到一半时服务器端的表现
    京ICP备2026067502号-1京公网安备11010502063487号