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