题干与适用场景
一个异步 API 需要并行读取用户资料、订单和推荐。任一关键任务失败时,剩余任务应尽快取消;客户端断开或总超时后, 不能留下继续运行的协程。请使用 Python 的 asyncio.TaskGroup 设计实现,并说明异常如何聚合、取消如何传播、资源如何清理。
Python 官方文档把 TaskGroup 描述为结构化并发工具:组内任务在作用域退出前完成;非取消异常会取消其他任务并以 ExceptionGroup 抛出。题目重点是精确描述生命周期,而不是把 create_task 调用堆在一起。
面试官考察点
看候选人是否理解任务组的父子关系、失败时的兄弟取消、CancelledError 的处理和 ExceptionGroup 的拆解;是否用 async with 确保 join;是否给数据库、HTTP 和文件操作传入取消信号;是否把必须在请求结束后继续的工作转成持久化队列。
回答前需要澄清的问题
- 三个读取是否同等关键,推荐失败能否返回降级结果?
- 每个子任务是否打开连接、游标或临时文件?
- 总超时由谁创建,调用方取消时如何传入?
- 错误是否需要按类型分类、重试或展示给用户?
- 哪些工作必须在 HTTP 响应后继续?
30 秒回答框架
“在 async with TaskGroup() 内创建三个子任务,离开作用域前等待它们结束。一个非取消异常会取消兄弟任务并抛出异常组;我用 except* 按错误类型记录和转换。每个任务在 finally 关闭资源,外层用 asyncio.timeout() 或调用方取消控制总预算。需要持久化的 工作不放在请求任务组里,而是写入可靠队列。”
分步骤深入解答
第一步:明确任务树和结果契约
先定义每个子任务的返回类型与关键性。用户和订单是必需数据,推荐可以是可选降级;这决定失败时是取消整组还是捕获单个异常并返回标记。 所有任务都由同一个请求作用域创建,不能把裸 create_task() 返回后遗忘。
async with asyncio.TaskGroup() as group:
user_task = group.create_task(load_user(user_id))
order_task = group.create_task(load_orders(user_id))
rec_task = group.create_task(load_recommendations(user_id))退出 async with 时,组会等待任务完成;调用方不应在块外读取一个尚未完成的任务来逃避 join。
第二步:处理失败与 ExceptionGroup
非 CancelledError 的异常会触发同组剩余任务取消,TaskGroup 在所有任务结束后抛出 ExceptionGroup。用 except* 处理可预期错误, 并保留未处理的异常让上层记录。
try:
async with asyncio.TaskGroup() as group:
user = group.create_task(load_user(user_id))
orders = group.create_task(load_orders(user_id))
except* RetryableDependencyError as errors:
record_dependency_failures(errors.exceptions)
raise ServiceUnavailable from errors不要捕获 BaseException 后静默吞掉取消;否则父任务无法知道请求已被中断。
第三步:把取消传到真实 I/O
TaskGroup 只能取消 Python task;底层 HTTP 客户端、数据库驱动和文件操作必须支持取消或超时参数。每个任务在 finally 关闭连接、释放信号量、 删除临时文件并停止消费。
async def load_orders(user_id: str):
conn = await pool.acquire()
try:
return await conn.fetch("SELECT ...", user_id, timeout=1.5)
finally:
await pool.release(conn)若驱动无法中断查询,使用独立受控连接、数据库 statement timeout 或后台作业,不能只依赖 task cancellation。
第四步:设置总超时并区分外部取消
用 asyncio.timeout() 包住整个任务组,确保三项工作共享一个总预算;不要给每项都重新发放完整时间。
try:
async with asyncio.timeout(2.0):
result = await aggregate(user_id)
except TimeoutError:
return degraded_response("deadline")
except asyncio.CancelledError:
raise外部取消应继续向上传播,不能转换为成功响应。超时是应用选择的截止策略,用户主动取消则应记录不同原因并停止工作。
第五步:理解 TaskGroup 与 gather 的差异
asyncio.gather() 默认会把第一个异常传播给等待者,但其他任务不一定自动取消;调用方若直接返回,可能留下孤儿任务。TaskGroup 把任务生命周期 绑定到作用域,并在失败时取消兄弟任务。
gather(return_exceptions=True) 会把异常当作结果,适合明确允许部分失败的场景,但需要逐项检查;它不替代结构化清理。不能在一个任务组里无限创建 后台任务,必须让工作范围有界。
第六步:测试异常顺序与资源清理
注入:用户任务先失败、推荐任务先失败、多个任务同时失败、调用方取消、总超时、驱动超时和 finally 自身出错。断言兄弟任务收到取消、所有资源归还、 没有未完成任务,并检查 ExceptionGroup 中每种错误可定位到子任务。
记录任务名、请求 ID、耗时、取消原因、异常类型和下游调用;不要记录用户隐私。用重复运行和慢 I/O 测试发现竞态,不能只测全都成功的路径。
高质量示范回答
“我在 async with TaskGroup() 内创建三个任务,定义用户和订单为必需、推荐可降级。任务组作用域保证 join;非取消异常会取消兄弟任务并形成 ExceptionGroup,我用 except* 分类记录。每个 I/O 任务把超时传给驱动,并在 finally 释放连接和临时资源。”
“外层 asyncio.timeout 提供共享预算,外部 CancelledError 继续传播。需要在响应后继续的工作写入可靠队列,不放在请求组内。测试同时失败、取消、超时和 慢驱动,确认无孤儿任务和资源泄漏。”
常见错误
- 裸
create_task后立即返回 → 产生孤儿任务 → 让任务属于 TaskGroup 作用域。 - 吞掉
CancelledError→ 父调用无法停止 → 清理后继续抛出取消。 - 每个子任务都给完整超时 → 总延迟失控 → 使用一个共享预算。
- 把
ExceptionGroup当成单个异常 → 丢失并行失败证据 → 用except*分类处理。 - 只取消 Python task → 数据库或 HTTP 仍运行 → 使用驱动 timeout 或可取消 I/O。
- 用
gather(return_exceptions=True)代替所有结构化并发 → 部分失败和清理语义不清 → 明确降级契约。
追问及应对
追问一:TaskGroup 是否保证取消瞬间停止底层 I/O?
不保证。它取消 Python task,底层驱动需支持取消或 statement timeout;否则使用受控连接或异步作业隔离。
追问二:推荐任务失败,为什么不取消整组?
先看业务契约。若推荐是可选结果,可以捕获该类异常并返回空推荐;若它影响安全或计费,就应让异常离开组并取消兄弟。
追问三:如何把 ExceptionGroup 转成 API 响应?
只把已知依赖错误映射为统一的 503 或降级结果,并记录每个子异常;未知错误继续抛出让全局处理器记录,不能把内部堆栈返回客户端。
追问四:哪些任务应该离开 TaskGroup?
需要在响应后继续的邮件、索引、批处理等工作应写入持久化队列,由独立 worker 重试;请求任务组只承载请求生命周期内的工作。
追问五:取消期间 finally 抛错怎么办?
保证清理路径最小且可观察;将清理异常作为附加上下文记录,优先保留原始取消或业务异常,避免 finally 覆盖根因。