详解Python asyncio.create_task与gather的正确用法
深入解析 Python asyncio.create_task 与 asyncio.gather 的核心运行机制、API 差异、协同模式与避坑指南,帮助开发者准确选择并发调度方式,规避垃圾回收与孤儿任务等隐蔽问题。
在 Python 异步编程中,asyncio.create_task 与 asyncio.gather 是使用频率最高却也最容易混淆的两个核心接口。简明而言:create_task 的职责是“单任务即时调度”——它把协程包装为 Task 实体并立即投递给事件循环就绪队列,赋予任务独立的生命周期控制权(支持主动取消、挂载回调或后台驻留);而 gather 的职责是“多任务批量汇聚”——它充当一道同步屏障,接收多个可等待对象并发执行,挂起当前调用方直至全部任务完成,并严格按传入顺序将结果收敛为列表返回。厘清这两者的底层分工与协作范式,是编写稳健无泄漏异步代码的基石。
一、asyncio.create_task 的工作原理与生命周期
许多初学者常犯的一个直觉错误,是以为调用了一个用 async def 定义的协程函数就会自动并发运行。实际上,单纯调用协程函数仅仅会得到一个惰性的协程对象(Coroutine Object),此时它根本没有被事件循环识别,也不会被分配任何 CPU 切片。
要让协程真正跑起来,有两种路径:要么通过 await coro 阻塞当前执行流等待其执行完毕,要么使用 asyncio.create_task(coro) 将其封装为 asyncio.Task。当调用 create_task 的那一瞬间,该 Task 就已经被正式注册到了当前正在运行的事件循环就绪队列中。
这意味着:即使你紧接着没有对其执行 await,只要当前协程在后续遇到任意一个释放控制权的 I/O 操作(例如 await asyncio.sleep(0)),事件循环就会立即调度刚刚创建的这个 Task 开始执行。下面的完整代码直观展示了这种“即发即排队”的非阻塞特性:
import asyncio
import time
async def background_worker(task_id: int, delay: float) -> str:
print(f"[{time.strftime('%X')}] 任务 {task_id}:开始在事件循环中运转...")
await asyncio.sleep(delay)
print(f"[{time.strftime('%X')}] 任务 {task_id}:执行完毕!")
return f"Result-{task_id}"
async def main():
print(f"[{time.strftime('%X')}] 主流程:准备派发两个后台任务")
# 步骤 1:调用 create_task 即刻将协程排入当前事件循环
task1 = asyncio.create_task(background_worker(101, 1.0))
task2 = asyncio.create_task(background_worker(102, 1.5))
print(f"[{time.strftime('%X')}] 主流程:任务已派发,尚未 await 任务,继续做其他业务...")
# 模拟主流程处理耗时 0.5 秒的非阻塞操作
# 在这个等待期间,事件循环会切片执行 task1 和 task2!
await asyncio.sleep(0.5)
print(f"[{time.strftime('%X')}] 主流程:业务处理完毕,现在准备获取任务结果")
# 步骤 2:在需要结果时显式 await 任务句柄
res1 = await task1
res2 = await task2
print(f"[{time.strftime('%X')}] 主流程:所有任务完成,收敛结果: {res1}, {res2}")
if __name__ == "__main__":
asyncio.run(main())
await asyncio.sleep(0.5) 时让渡了 CPU 控制权,此时任务 101 和 102 已经立刻输出“开始在事件循环中运转...”。这证明 create_task 的调度并不依赖于对它自身的 await。
二、asyncio.gather 的运行机制与异常处理
与针对单个任务的 create_task 不同,asyncio.gather 面向的是“多任务集合”。其核心语法定义如下:
asyncio.gather(*aws, return_exceptions=False) -> Future
当你向 gather 传入多个可等待对象(可以是裸协程、已经创建的 Task 或 Future)时,它在内部会自动完成两件事:
- 若传入的是原生协程对象,
gather会自动调用底层类似ensure_future/create_task的逻辑将其全部封装为 Task,并激活并发调度; - 注册一个聚合管理器,挂起当前协程,直到所有传入的任务全部达到终态,最后严格按照参数传入的顺序组合为一个结果列表返回。
核心参数剖析:return_exceptions 的选择与隐患
gather 最关键的参数是布尔值 return_exceptions,它的默认值是 False。很多线上事故正是因为对这个参数的误解引起的:
-
当
return_exceptions=False(默认):快速失败模式
一旦并发执行的某个子任务抛出了未捕获异常,gather会立刻将该异常向外抛出给调用方!调用处的代码会被打断进入 except 分支。但是请务必注意:此时其他正在并发执行的子任务并不会被自动取消! 它们依然会继续在事件循环的后台不受控地消耗 CPU 和网络 I/O,沦为“孤儿任务”。 -
当
return_exceptions=True:安全容错汇集模式
任何子任务发生异常都不会中断其他任务,也不会向外抛错。gather会耐心等待所有子任务全部执行完毕,然后将异常实例(如ValueError())像普通数据一样填充在返回列表中对应索引位置。调用方必须通过isinstance(item, Exception)自行做业务分流。
import asyncio
async def fetch_user_profile(user_id: int):
await asyncio.sleep(0.3)
if user_id < 0:
raise ValueError(f"非法用户ID: {user_id}")
return {"user_id": user_id, "name": f"User_{user_id}"}
async def fetch_order_history(user_id: int):
await asyncio.sleep(0.5)
return [f"Order_A_{user_id}", f"Order_B_{user_id}"]
async def fetch_user_points(user_id: int):
await asyncio.sleep(0.2)
return 1500
async def main():
print("--- 场景 1:return_exceptions=False (默认快速失败) ---")
try:
# 传入非法 user_id=-1 模拟其中一个子服务报错
results = await asyncio.gather(
fetch_user_profile(-1),
fetch_order_history(100),
fetch_user_points(100),
return_exceptions=False
)
print("执行结果:", results)
except ValueError as e:
print(f"捕获到 gather 穿透抛出的首个异常: {e}")
print("【警惕】:fetch_order_history 仍在后台继续执行,未被自动取消!")
print("
--- 场景 2:return_exceptions=True (容错汇聚模式) ---")
# 开启容错:所有任务全部等待完毕,异常作为结果项返回
mixed_results = await asyncio.gather(
fetch_user_profile(-1),
fetch_order_history(100),
fetch_user_points(100),
return_exceptions=True
)
# 逐项解析收敛结果
for idx, item in enumerate(mixed_results):
if isinstance(item, Exception):
print(f"索引 {idx} 执行失败,异常详情: {type(item).__name__} -> {item}")
else:
print(f"索引 {idx} 执行成功,返回数据: {item}")
if __name__ == "__main__":
asyncio.run(main())
三、架构协同:何时用 create_task,何时用 gather?
在实际工程中,create_task 与 gather 并不是非此即彼的对立关系。很多时候,它们是上下游协同作战的伙伴。
1. 必须/优先使用 create_task 的场景
- 后台驻留与即发即走(Fire-and-forget):例如 Web 请求进入后,需要异步记录审计日志、推送非核心统计指标。主请求必须立即响应给客户端,不需要同步等待后台子任务完成。
-
单任务的细粒度生命周期管理:当某个子任务需要单独设置超时(如
asyncio.wait_for(task, timeout=2.0))、需要主动挂载特定完成通知(task.add_done_callback(...)),或者在特定条件下单独执行task.cancel()时,必须使用create_task持有 Task 对象引用。 - 生产者-消费者流水线:事件队列中动态涌入请求,生产者按需逐一创建 Task 并在队列中排队,不需要成批打包同步。
2. 优先使用 gather 的场景
- 经典扇出扇入(Fan-out / Fan-in):已知固定的一组异步操作(例如并发查询 MySQL、Redis 缓存与下游 RPC 接口),必须全部拿到结果后才能组装最终响应给前端。
-
强依赖结果顺序的批处理:输入列表是一组有序的 URL,要求返回的结果列表严格对应输入的顺序。
gather能百分之百保证即使不同任务完成时间不同,返回列表的索引顺序始终与入参顺序严密一致。
3. 两者的经典组合实践
当批处理任务中每个子任务需要独立的超时或取消保护,但最终又需要统一在主流程汇合时,最佳实践是先用 create_task 派发,再用 gather 汇聚:
import asyncio
async def worker(name: str, delay: float):
await asyncio.sleep(delay)
return f"Data from {name}"
async def main():
endpoints = [("API_A", 0.5), ("API_B", 1.2), ("API_C", 0.8)]
# 步骤 1:使用 create_task 逐一包装并派发入事件循环,记录任务句柄
tasks = []
for name, delay in endpoints:
t = asyncio.create_task(worker(name, delay), name=f"Task-{name}")
tasks.append(t)
print(f"已成功为 {len(tasks)} 个节点创建并发 Task 句柄")
# 步骤 2:解包传入 gather 进行统一同步屏障等待与结果收敛
results = await asyncio.gather(*tasks, return_exceptions=True)
print("汇聚完成,全部结果列表:", results)
if __name__ == "__main__":
asyncio.run(main())
四、生产环境必须避开的三大隐蔽陷阱
陷阱 1:无强引用导致的 Task 中途被 GC 垃圾回收(致命陷阱)
这是 Python 3.8 及更高版本官方文档中明确给出的严重安全警示,也是无数资深工程师排查数天才能揪出的偶发性 Bug。
“Save a reference to the result of this function, to avoid a task disappearing mid-execution. The event loop only keeps weak references to tasks. A task that isn't referenced elsewhere may be garbage collected at any time, even before it's done.”
(请务必保存 create_task 函数返回的结果引用,以防任务在执行中途消失。事件循环内部仅保留对任务的弱引用。在其他地方未被引用的任务随时可能被垃圾回收器回收,甚至在其执行完毕之前。)
错误写法(极其危险):
async def fire_and_forget():
# 错误!没有变量持有返回的 task 句柄
# 任务进入事件循环后,只要触发一次垃圾回收(GC),该任务就可能被销毁而静默中断!
asyncio.create_task(send_audit_log_coroutine())
生产级安全标准范式:
import asyncio
# 模块级或上下文级持有强引用集合
background_tasks = set()
def create_safe_background_task(coro):
task = asyncio.create_task(coro)
# 1. 显式放入强引用集合,防止被 Python GC 机制意外销毁
background_tasks.add(task)
# 2. 注册终态回调:任务自然结束、异常或取消时,自动从集合中移除,防止内存泄漏
task.add_done_callback(background_tasks.discard)
return task
async def audit_job():
await asyncio.sleep(1.0)
print("后台审计日志持久化成功!")
async def main():
create_safe_background_task(audit_job())
print("主流程非阻塞向下执行,后台任务已安全挂载...")
await asyncio.sleep(1.5)
if __name__ == "__main__":
asyncio.run(main())
陷阱 2:gather 默认快速失败导致的“后台孤儿任务泄露”
在第二节中我们分析过,gather(t1, t2, t3) 在默认情况下,若 t1 抛出异常,await gather 会立即向外抛错。然而,此时 t2 和 t3 并没有被停止,它们仍然在事件循环中持续进行网络传输或消耗内存。
若想在发生异常时优雅中断并清理其余全部并发任务,在 Python 3.11+ 推荐使用现代结构化并发上下文 asyncio.TaskGroup();而在 Python 3.8-3.10 环境中,应在 try-except 中主动遍历取消未完成的任务:
import asyncio
async def fetch_step(num: int):
await asyncio.sleep(num * 0.5)
if num == 2:
raise RuntimeError("节点 2 出现致命崩溃")
return num
async def safe_gather_all():
tasks = [asyncio.create_task(fetch_step(i)) for i in [1, 2, 3]]
try:
results = await asyncio.gather(*tasks)
return results
except Exception as e:
print(f"检测到批处理异常: {e},主动执行清理以避免孤儿任务...")
for t in tasks:
if not t.done():
t.cancel() # 发送取消信号
# 确保被取消的任务完成清理
await asyncio.gather(*tasks, return_exceptions=True)
raise e
陷阱 3:混淆协程对象(Coroutine)与任务(Task)的执行时态
不少开发者写出如下代码并困惑为什么没有并发效果:
# 伪并发反例:这仍然是绝对串行的!
coro1 = download(url1)
coro2 = download(url2)
# 这不是并发!await coro1 会完全阻塞当前函数,执行完毕后才轮到 coro2
res1 = await coro1
res2 = await coro2
直接 await coro1 时,事件循环只专注于推进这个单一协程,直到它 return 为止;而 coro2 此时甚至还没被注册进事件循环。想要真正的并发,必须要么在声明阶段用 create_task 将两者都排入事件循环就绪队列,要么直接使用 await asyncio.gather(coro1, coro2)。
五、总结:核心选型决策树与属性矩阵
为了帮助读者在日常开发与架构设计中快速做出准确的技术选型,以下图示梳理了从业务诉求到 API 选型的完整决策路径,并在下方给出了核心属性对比矩阵。
| 特性维度 | asyncio.create_task | asyncio.gather |
|---|---|---|
| 接口定位 | 单任务生成器与排队调度入口 | 多任务聚合器与同步等待屏障 |
| 调度启动时机 | 函数调用瞬间立即打包入列就绪队列 | 传入原生协程时内部自动包装并启动 |
| 返回值类型 | 单个 asyncio.Task 对象句柄 |
聚合的 Future,await 返回结果列表 |
| 异常传播策略 | 内部留存,显式 await 或 task.result() 时暴露 | 默认抛出首个异常;或设为 True 作为列表项返回 |
| 任务控制粒度 | 极高(支持单独取消、单独超时、注册独立回调) | 粗粒度(作为一个不可分割的整体收敛等待) |
| 垃圾回收风险 | 极高:事件循环仅持弱引用,外部必须保持强引用 | 低:gather 内部闭包自动维持各子任务的引用链 |
| 典型适用场景 | 后台非阻塞作业、分布式消息轮询、复杂控制流 | 批量 I/O 扇出汇聚、多源数据抓取、接口聚合返回 |
掌握 create_task 与 gather 的分工,本质上是掌握了异步编程中“任务派发”与“结果收敛”两道关键关卡。在日常编码中,遇到需要并行启动并按序获取全量结果的批处理,果断选择 asyncio.gather(并审慎配置 return_exceptions);遇到需要独立管控、设置个性化超时或解耦主流程的后台作业,规范使用 asyncio.create_task 并始终通过强引用集合防范 GC 丢失,就能彻底杜绝隐蔽的并发死锁、资源泄露与任务蒸发问题。
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









