一个让人崩溃的周末
先说说我是怎么踩到这个坑的。
上周末,我写了一个爬虫脚本,要同时从三个不同的 API 接口拉数据。逻辑很简单——三个接口互不依赖,谁先返回都行,我只要把结果汇总到一起。
一开始我用的是 asyncio.gather,代码大概是这样的:
async def fetch_user_info():
await asyncio.sleep(1)
return {"name": "张三"}
async def fetch_order_history():
await asyncio.sleep(2)
return {"orders": [1, 2, 3]}
async def fetch_recommendations():
await asyncio.sleep(3)
return {"recs": ["商品A", "商品B"]}
async def main():
results = await asyncio.gather(
fetch_user_info(),
fetch_order_history(),
fetch_recommendations()
)
print(results)
跑起来很顺利,三个任务并发执行,总耗时 3 秒,比串行的 6 秒快了一倍。
然后问题来了——第二个接口偶尔会超时抛异常。按我的理解,gather 应该会把这个异常捕获住,然后我统一处理就行了。结果实际情况是:只要有一个任务抛出异常,整个 gather 直接炸掉,其他两个任务也被取消,什么都没拿到。
三个接口,一个出问题,全盘皆输。这显然不是我想要的。
当时我脑子里只有一个念头:这玩意儿怎么这么不智能?
后来我才发现,不是 gather 不智能,是我压根没搞清楚它的异常处理逻辑。
gather 的默认行为:一损俱损
asyncio.gather 的设计哲学是 “原子性” ——它把传进去的一堆任务当作一个不可分割的整体。要么全部成功,要么全部失败。只要有一个任务抛出异常,gather 会立即把这个异常抛给调用者,同时取消其他所有未完成的任务。
这个设计在有些场景下是合理的。比如你要批量更新数据库,必须保证所有更新都成功才算完成,任何一个失败整个操作都应该回滚。这时候 gather 的“一损俱损”恰恰是你需要的。
但在我这个爬虫场景里,三个接口互不依赖,一个接口挂了不应该影响另外两个。我需要的是 “各自安好” ,而不是“连坐”。
return_exceptions:gather 的“容错开关”
后来翻文档才发现,gather 其实提供了一个参数叫 return_exceptions,专门用来控制异常处理行为。
默认是 False,也就是“一有异常就抛出”。改成 True 之后,行为完全不一样了:
results = await asyncio.gather(
fetch_user_info(),
fetch_order_history(), # 这个会抛异常
fetch_recommendations(),
return_exceptions=True # 关键在这里
)
for result in results:
if isinstance(result, Exception):
print(f"捕获到异常: {result}")
else:
print(f"正常结果: {result}")
设置 return_exceptions=True 之后,gather 不会抛出异常,而是把异常对象当作普通结果一起放到返回列表里。这样所有任务都会执行完,成功的有结果,失败的返回异常对象,你自己来判断和处理。
这个方案完美解决了我的问题——接口 2 超时了,接口 1 和接口 3 的数据照样能拿到。
wait:更底层的“监控器”
除了 gather,asyncio 还提供了一个更底层的 API 叫 asyncio.wait。
如果说 gather 是一个“任务聚合器”,那 wait 更像一个 “状态监控器” 。它不直接返回结果,而是返回两个集合:done(已完成的任务)和 pending(未完成的任务)。
done, pending = await asyncio.wait([
fetch_user_info(),
fetch_order_history(),
fetch_recommendations()
])
for task in done:
try:
result = task.result()
print(f"成功: {result}")
except Exception as e:
print(f"任务失败了: {e}")
和 gather 不同,wait 默认不会自动传播异常。任务抛了异常就抛了,wait 只管告诉你“这个任务做完了”,至于结果是成功还是异常,你得自己调用 task.result() 去拿——如果是异常,result() 会重新抛出。
换句话说,gather 默认帮你“代劳”了异常处理(直接抛给你),而 wait 把异常“藏”在任务里,让你自己去取。前者是主动出击,后者是你主动去问。
wait 的进阶玩法:FIRST_EXCEPTION
wait 比 gather 强大的地方在于,它可以通过 return_when 参数精确控制“什么时候返回”。
默认是 ALL_COMPLETED——等所有任务都完成。但你还可以选:
FIRST_COMPLETED:任意一个任务完成就返回FIRST_EXCEPTION:任意一个任务抛出异常就返回
FIRST_EXCEPTION 特别有用。假设你同时发起多个请求,只要有一个失败了你就想立刻停止并处理,不需要等其他的。用 wait 可以这样写:
done, pending = await asyncio.wait(
tasks,
return_when=asyncio.FIRST_EXCEPTION
)
# 检查 done 里有没有异常
for task in done:
if task.exception():
# 有一个任务失败了,取消所有 pending 的任务
for p in pending:
p.cancel()
raise task.exception()
这种精细控制是 gather 做不到的。gather 的 return_exceptions=True 虽然能容错,但无法实现“一有异常就立刻中断并返回”的逻辑——它要么全等(默认),要么全等但返回异常对象(return_exceptions=True)。
一张表说清楚
| 对比维度 | asyncio.gather | asyncio.wait |
|---|---|---|
| 返回内容 | 结果列表(按输入顺序) | done 和 pending 两个任务集合 |
| 默认异常处理 | 自动传播第一个异常,取消其他任务 | 不自动传播,需手动检查 |
| 容错模式 | return_exceptions=True 将异常作为结果返回 | 手动遍历 done 集合,调用 task.exception() |
| 返回时机控制 | 全部完成(或遇到异常提前中断) | ALL_COMPLETED / FIRST_COMPLETED / FIRST_EXCEPTION |
| 适用场景 | 批量获取结果、事务一致性要求高的场景 | 任务监控、动态调度、需要精细控制的场景 |
什么时候该用谁?
经过这次踩坑,我给自己定了一个简单的原则:
如果你要的是“全部结果,按顺序拿”,用 gather。 它简单、直接,配合 return_exceptions=True 也能优雅地处理异常。
如果你需要“监控任务状态、根据情况决定下一步”,用 wait。 它更灵活,能告诉你哪些做完了、哪些还在跑、哪个先出了异常。
如果异常发生时你想立刻停止所有任务并处理,用 wait 配合 FIRST_EXCEPTION。
如果某个任务失败了你不想影响其他任务,用 gather 配合 return_exceptions=True。
没有哪个绝对好,只有哪个更适合你的场景。
写在最后
那次爬虫事件之后,我每次用 asyncio.gather 都会下意识地看一眼需不需要加 return_exceptions=True。这个习惯帮我避免了好几次线上事故。
其实 Python 的异步编程就是这样——API 看起来差不多,用起来天差地别。gather 和 wait 的区别只是冰山一角,类似的坑还有 create_task 和 ensure_future、shield 和 wait_for……
每一个坑踩过一次,下次就知道了。
希望你不用踩同样的坑。