task.cancel() 之后还在跑:asyncio 超时任务的三层收尾
task.cancel() 之后还在跑:asyncio 超时任务的三层收尾
在异步服务里,给一次调用加上 timeout 很容易,但“超时之后任务已经真正结束”并不自动成立。常见的线上现象是:请求在 3 秒后返回 504,后台却仍有一个协程占着数据库连接;或者用户连续重试,旧任务没有被回收,新的任务又在同一资源上排队。问题往往不在等待本身,而在取消后的收尾。
一、超时只是发出取消请求
以 Python 3.11 的 asyncio.timeout 为例,超时到达时,事件循环会在当前任务内抛出 CancelledError。业务代码如果吞掉这个异常,外层就会误以为操作已经停止。需要区分“业务失败”和“任务被取消”:前者可以转换成错误响应,后者通常必须继续向上传播。
async with asyncio.timeout(3):
data = await fetch(url)
不要捕获 Exception 后统一返回默认值,因为这会破坏取消语义;如果要处理 CancelledError,通常只做必要的记录,然后原样 raise。
二、清理阶段必须可等待
真正容易遗漏的是 finally 里的异步清理。关闭连接、释放锁、删除临时文件都要允许等待完成;但清理代码本身不应无限等待。可以为清理动作设置更短的截止时间,同时避免在取消处理中又发出大量新请求。
当多个任务共享连接池时,建议由每一层只管理自己创建的资源。上层的超时不能替代下层的关闭;下层也不应因为一次取消就破坏仍被其他任务使用的共享对象。资源所有权清楚,排查时才能回答“这个连接是谁创建、由谁释放”。
三、不要让 shield 变成后台泄漏
有些代码用 asyncio.shield() 保护工作协程,避免等待方被取消时打断真正的写入。但保护并不等于可以忘记它。更稳妥的写法是:超时后显式取消工作协程,并等到它退出后再返回。
worker = asyncio.create_task(job.run())
try:
await asyncio.wait_for(asyncio.shield(worker), timeout=5)
except TimeoutError:
worker.cancel()
with contextlib.suppress(asyncio.CancelledError):
await worker
raise
这段代码的关键不是处理超时,而是最后的 await worker。它确认取消已经落地,避免接口已经失败、后台任务却继续读写外部系统。
四、上线前的检查项
发布异步任务前,可以逐项确认:超时是否覆盖最外层调用;业务异常是否会误吞取消;finally 中的清理是否有独立边界;共享资源是否只有一个所有者;取消后是否真正 await 到结束;日志是否同时记录任务 ID、开始时间、超时时间和清理结果。
超时控制的目标不是尽快返回错误,而是尽快让整条执行链进入可预测的稳定状态。把取消传播、资源清理和后台任务回收一起设计,才能避免“请求已经超时,工作还在继续”的隐性泄漏。
- 点赞
- 收藏
- 关注作者
评论(0)