Skip to content

asyncio.gather 里一个任务失败了,其他任务还在跑 ​

await asyncio.gather(a(), b(), c()),b 抛了异常,调用方的 except 捕获到了,接着返回错误。此时 a 和 c 仍在事件循环里运行,继续占着连接、继续写数据。把协程套进 wait_for 设了 50ms 超时,结果 400ms 后它正常返回了,没有任何超时异常。

asyncio 的并发原语对「一个任务失败或超时之后,其余任务怎么办」各有各的约定,而且取消在 Python 里是一个协程可以拦下来的异常。Java 里类似的问题见超时了、取消了,任务其实还在跑;Python 这边的规则不同,更容易在不知不觉中留下没人管的任务。

本文在 CPython 3.14.8 上验证。实验里每个协程每 5ms 累加一次计数,失败、取消或超时发生之后隔 100ms 再看计数是否还在增长,以此判断协程本身有没有停下。实验见文末。

一、gather 不取消,TaskGroup 取消 ​

三个任务并发,其中一个 50ms 后抛出 ValueError:

asyncio.gather任务 A继续运行到结束,没有人再等它的结果任务 B运行抛出 ValueError调用方await gather(…)收到 ValueError,继续往下执行asyncio.TaskGroup任务 A运行收到 CancelledError,执行 finally 后结束任务 B运行抛出 ValueError调用方async with tg所有任务结束后,收到 ExceptionGroup50ms:任务 B 失败gather(…, return_exceptions=True) 不会提前返回:等全部任务结束,把异常当作结果放在列表里
图 1 · 三个并发任务,其中一个在 50ms 时失败。gather 把异常抛给调用方,另外两个任务没有被取消,继续跑完;调用方已经在处理异常,后台还有两个没人管的任务。TaskGroup 取消另外两个任务,等它们的 finally 执行完才离开 async with(横轴为示意,不按比例)
写法调用方得到另外两个任务
gather(a, b, c)立即收到 ValueError仍在运行
gather(a, b, c, return_exceptions=True)等全部结束(200ms)后得到 ['done', ValueError, 'done']跑完
async with TaskGroup()ExceptionGroup,内含 ValueError已被取消并退出,finally 已执行

gather 的文档写明:第一个异常会立即传给等待 gather 的任务,其余的不会被取消,继续运行。它们成了后台任务,结果和异常都没有人接收。如果调用方接着重试,上一轮的任务和这一轮的任务会同时在跑。

return_exceptions=True 走的是另一条路:永远不提前返回,把异常当作普通结果放进列表。它适合「各做各的,最后汇总」的场景,调用方必须逐个检查结果的类型,否则异常就被当成数据处理了。

TaskGroup(Python 3.11 起)是需要「一个失败,全部停下」时的写法。一个任务失败后,它取消其余任务,并且等它们都结束才离开 async with。离开代码块时没有遗留的任务,这一点和 Java 的结构化并发相同。异常以 ExceptionGroup 抛出,用 except* 处理:

python
try:
    async with asyncio.TaskGroup() as tg:
        order = tg.create_task(load_order(order_id))
        user = tg.create_task(load_user(order_id))
except* TimeoutError:
    ...          # 可能有多个任务同时失败,except* 按类型分别处理

新代码里并发执行一组相关任务,优先用 TaskGroup。

二、取消是一个可以被拦下的异常 ​

task.cancel()登记取消请求任务状态done=False,cancelled=False事件循环恢复协程在停着的 await 处抛出 CancelledErrorfinally 清理后继续抛出任务结束,cancelled=Trueexcept 捕获后不再抛出任务继续运行,cancelled=False没有 await 的一段代码不会被取消打断:长时间的纯计算要自己拆开,或者放到线程、进程里
图 2 · task.cancel() 只是登记一个请求,调用刚返回时任务既没结束也没被标记为已取消。事件循环下一次恢复这个协程时,在它停着的 await 处抛出 CancelledError。协程在 finally 里清理、让异常继续传播,任务才算被取消;捕获后不再抛出,任务照常运行,wait_for 的超时也跟着失效

task.cancel() 的过程分两步,实测:

时刻task.done()task.cancelled()协程
cancel() 刚返回FalseFalse还没被恢复执行
让出事件循环之后TrueTrue已退出,finally 已执行

cancel() 只是提出请求。协程下一次被恢复时,在它停着的 await 处收到 CancelledError。这带来两个结论。

没有 await 的地方不会被取消。 一段长时间的纯计算,或者一个阻塞调用,取消请求要等它跑完、走到下一个 await 才生效。

协程可以把取消吞掉。 CancelledError 是异常,写了 except 就能拦住:

python
while True:
    try:
        await asyncio.sleep(0.005)
    except asyncio.CancelledError:
        pass                         # 拦下取消,循环继续

实测这样的协程在 cancel() 之后仍在运行,最终 task.cancelled() 是 False,结果是正常返回值。CancelledError 从 Python 3.8 起继承自 BaseException,所以 except Exception 拦不住它,这是有意的设计;会出问题的是裸 except:、except BaseException: 和上面这种显式捕获后不再抛出的代码。清理工作放在 finally 里,或者捕获后做完清理再 raise。裸 except、finally 里的 return 等其他会吞掉异常的写法,见 python -O 之后,参数校验全没了。

三、超时靠取消实现,所以也会失效 ​

wait_for 和 asyncio.timeout() 到时间后做的事情是取消里面的协程,然后等它真的结束,再抛 TimeoutError:

写法结果
wait_for(work(), 0.05),协程正常响应取消约 50ms 抛出 TimeoutError,协程已退出
async with asyncio.timeout(0.05)抛出 TimeoutError,协程已退出
wait_for(work(), 0.05),协程吞掉取消约 400ms 后正常返回 'done',没有超时异常

第三行:协程拦下了取消,一直跑到自己结束(400ms),wait_for 等到了它的返回值,于是把返回值交给了调用方。50ms 的超时对这个调用没有产生任何效果,日志里也看不到任何痕迹。

和 Java 相比,这里的保证更强也更脆弱:正常情况下,超时返回时任务已经确实停了(Java 的 Future.get 超时不取消任务);但只要调用链上任何一层吞掉了 CancelledError,超时就整个不起作用。第三方库里一个宽泛的 except 就足够了。超时没有生效时,先在被调用的协程里找捕获取消的地方。

四、create_task 的返回值要留着 ​

事件循环对任务只保存弱引用。只创建、不保存引用的任务,可能在运行途中被垃圾回收:

写法垃圾回收之后
asyncio.create_task(coro()),不保存返回值asyncio 日志:Task was destroyed but it is pending!
task = asyncio.create_task(coro())任务还在,没有日志

任务等待的对象如果只被它自己引用,一次垃圾回收就能让它消失,后面的代码不会执行,也没有异常。实际代码里这取决于任务当时在等什么,所以表现为偶发。文档给出的做法是把任务放进一个集合,完成时移除:

python
background = set()

task = asyncio.create_task(send_notification(order))
background.add(task)
task.add_done_callback(background.discard)

无人等待的后台任务还有一个问题:异常是沉默的。实测一个后台任务抛出异常,只要任务对象还被引用着,日志里什么都没有;直到引用释放、任务对象被销毁,才出现 Task exception was never retrieved。长期持有引用的任务,异常可能永远不出现在日志里。给后台任务加 add_done_callback,在回调里检查 task.exception() 并记录。

五、事件循环只有一个线程 ​

所有协程在同一个线程里轮流执行,一个协程不让出,其余的都得等。

实测协程里直接调用 time.sleep(0.3),另一个每 10ms 跳一次的心跳协程,最大间隔不少于 300ms;改成 await asyncio.to_thread(time.sleep, 0.3) 之后小于 100ms。同步的数据库驱动、requests、大文件读写、长时间的计算,放在协程里效果都一样:这 300ms 里,服务不处理任何其他请求,健康检查也不响应。阻塞调用要换成异步库,或者用 to_thread 放到线程里。

「单线程,所以没有竞态」只对了一半。协程只在 await 处切换,两个 await 之间的代码不会被打断;跨了 await 就不成立:

python
current = counter
await something()        # 让出:别的协程在这里读到同一个 current
counter = current + 1
100 个协程各加 1结果
读和写之间有 await1
读和写之间没有 await100

「先查询、再 await、再根据查询结果写入」是这个问题在业务代码里的样子:检查库存后扣减、检查缓存后回填。需要互斥时用 asyncio.Lock。

六、检查清单 ​

  1. 一组相关任务用 TaskGroup;用 gather 时想清楚其中一个失败后其余的去向。
  2. 不吞 CancelledError:清理放 finally,捕获后重新抛出;避免裸 except:。
  3. 超时没生效时,查调用链上谁拦下了取消。
  4. 保存 create_task 的返回值;后台任务用完成回调记录异常。
  5. 协程里不做阻塞调用;不确定的库用 to_thread,并打开 asyncio 的调试模式找出执行过久的回调。
  6. 跨 await 的「读、改、写」加 asyncio.Lock。
  7. 停机时取消所有任务并等待它们结束,给清理留出时间。

CPU 密集的工作放进线程能不能用上多核,取决于解释器的构建方式,见 Python 线程能不能用上多核。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权