asyncio 中超时和取消任务怎么处理?CancelledError 要注意什么?
简化版
asyncio 里可以用 asyncio.wait_for() 或 asyncio.timeout() 控制超时,超时时通常会取消被等待的任务。取消通过向协程注入 CancelledError 实现,协程应在 finally 中清理资源,不要随便吞掉取消异常,否则任务可能无法按预期停止。
详细版
超时示例:
async def main():
try:
result = await asyncio.wait_for(fetch(), timeout=3)
except asyncio.TimeoutError:
print("timeout")
现代写法也可以使用超时上下文:
async def main():
try:
async with asyncio.timeout(3):
result = await fetch()
except TimeoutError:
print("timeout")
取消任务:
task = asyncio.create_task(worker())
task.cancel()
try:
await task
except asyncio.CancelledError:
print("cancelled")
注意点:
- 取消不是强杀,而是在任务下一个可取消点抛出
CancelledError; - 协程应使用
try/finally做资源清理; - 不要无脑
except Exception以为能捕获取消; - 不要吞掉
CancelledError后继续假装任务正常完成; - 对关键清理逻辑可以了解
asyncio.shield(),但不要滥用。
完整版教学
一、异步任务为什么必须有超时
网络请求、数据库查询、第三方接口都可能卡住。如果没有超时,任务可能一直占着资源:
- 连接不释放;
- 队列堆积;
- 请求一直等待;
- 服务优雅关闭被拖住。
异步系统通常并发量更高,一个没超时的外部依赖可能拖住大量任务。
二、wait_for 的基本语义
asyncio.wait_for() 等待某个 awaitable 在指定时间内完成:
await asyncio.wait_for(fetch(), timeout=3)
如果超时,它会触发取消,并抛出超时异常。这里要理解:超时不是简单“不等了”,它还会尝试取消内部任务。
如果内部协程有清理逻辑,事件循环会给它机会处理取消。
三、asyncio.timeout 的可读性
超时上下文适合包住一段异步逻辑:
async with asyncio.timeout(5):
user = await get_user()
orders = await get_orders(user.id)
它表达的是“这一整段必须在 5 秒内完成”。相比每个 await 单独写 wait_for,结构更清晰。
四、取消不是强制杀死
调用:
task.cancel()
并不等于操作系统层面的强杀。它请求取消任务,任务通常会在下一个 await 点收到 CancelledError。
async def worker():
try:
await do_work()
finally:
await cleanup()
这就是为什么协程要在 finally 里清理资源,比如关闭连接、释放锁、回滚状态。
五、为什么不能随便吞掉 CancelledError
错误写法:
async def worker():
try:
await do_work()
except asyncio.CancelledError:
pass
如果吞掉取消异常,外部可能以为任务正常完成,任务组或上层取消逻辑也可能被破坏。更好的做法是清理后重新抛出:
async def worker():
try:
await do_work()
except asyncio.CancelledError:
await cleanup()
raise
这代表“我知道被取消了,清理完继续告诉上层我确实取消了”。
六、shield 的作用和风险
asyncio.shield() 可以保护某个 awaitable 不因外层取消而直接取消。它适合极少数必须完成的清理或提交动作。
但 shield 很容易让取消语义复杂化。如果所有东西都 shield,服务关闭时就会拖泥带水。面试中知道它存在即可,回答重点仍然是超时、取消、清理和异常传播。
七、常见误区与追问
| 机制 | 作用 | 风险点 |
|---|---|---|
wait_for | 给单个 awaitable 加超时 | 超时会取消内部任务 |
asyncio.timeout | 给代码块设置超时上下文 | 可读性更适合包住一段逻辑 |
CancelledError | 协程取消信号 | 不应随便吞掉 |
shield | 保护内部任务不被外层取消直接影响 | 可能造成任务继续后台运行 |
- 误区:取消任务等于立刻杀掉任务。 asyncio 的取消是协作式的,任务在下一个可取消的
await点收到CancelledError。 - 误区:捕获 Exception 就会捕获取消。 在现代 Python 中
CancelledError的继承关系和版本有关,面试重点是不要把取消当普通业务异常吞掉。 - 误区:shield 能让任务永远不受取消影响。
shield只是隔离外层取消对内部 awaitable 的直接传播,内部任务仍可能因自身逻辑或其他路径被取消。 - 追问:为什么必须给外部调用加超时? 网络、RPC、数据库调用没有超时会长期占住连接、协程和业务请求,最终造成级联阻塞。
- 追问:取消时 finally 还会执行吗? 会,协程应在
finally中释放连接、锁、文件等资源,然后通常继续向外传播取消。 - 追问:wait_for 和 asyncio.timeout 怎么选? 单个 awaitable 可用
wait_for;一段包含多次 await 的业务流程用asyncio.timeout更清楚。
易错点:超时通常通过取消实现,所以写异步代码时要把“释放资源”和“不要吞 CancelledError”放在同一套思路里。
八、加强记忆
超时是为了给等待设置边界,取消是为了让任务有序停止。wait_for 和 asyncio.timeout 负责超时控制,task.cancel() 负责请求取消,CancelledError 是协程收到的停止信号。清理资源用 finally,取消异常清理后通常要继续抛出,不要悄悄吞掉。