☰
掌握asyncio任务取消与优雅退出:从cancel到SIGTERM全流程
2026/10/17 13:59:53 网站建设 项目流程

你有没有遇到过这种场景:明明在主程序里调用了task.cancel(),日志里却还在一条一条打业务数据;明明给进程发了 SIGTERM,它却赖着不走,最后只能靠kill -9收场;更头疼的是,程序倒是退出了,数据库连接池、文件句柄、后台清扫任务全都没来得及收尾,第二天一查监控全是报警。

我最早写 asyncio 代码的时候,以为cancel()就是“干掉一个任务”,后来才慢慢明白,事件循环里的取消和线程里的 stop 根本不是一回事。协程的取消更像一封通知信:协作式调度决定了协程只有运行到 await 挂起点时才有机会收到这封信;收到之后,它可以选择配合退出,也可以假装没看见。而这封“通知信”一旦落到不配合的代码里,就会变成超时、卡死、资源泄漏的来源。

这篇文章想把我在这块攒下的经验做一次系统整理:从cancel()和CancelledError的底层行为,到超时、用户中断、级联失败几个高频场景,再到优雅退出的完整链路设计。它不是 Python 新手的入门教程,更适合那些已经用 asyncio 写过业务,却总觉得退出、取消这块“说不清、调不稳”的开发者。看懂之后,你至少能把一个服务在收到退出信号时如何收摊这件事,安排得明明白白。

1. 为什么“取消”在 asyncio 里不是一个简单的动作

1.1 协作式调度决定了取消只能“协商”,不能“强杀”

asyncio 本质上跑在单线程里,靠事件循环不断切换协程。协程与协程之间靠 await 让出控制权,谁不让出,谁就占着 CPU 不放。这种模型有个直接推论:你想取消一个协程,不可能像线程那样由操作系统在任意指令边界把它掐掉。你只能向它“投递”一个信号,等它下一次恢复执行时自己处理。

这个信号就是asyncio.CancelledError。task.cancel()做的事,并不是停止协程,而是通知事件循环“我希望这个任务在下一个挂起点被取消”。事件循环在后续的调度中,会把CancelledError抛到协程当前的 await 处。协程收到之后,可以选择立刻收拾东西退出,也可以选择把信撕了继续跑。所以很多文档里把取消描述成“协作式取消”,它本质上是一次协商,而不是一道强杀命令。

用一个生活类比:你叫一个正在专心写代码的同事去喝茶,他不会立刻放下键盘,而是要等手头这个函数写完、走到茶水间时才有空回应你。协程也是这样,如果它正卡在一个不包含 await 的循环里,或者正等在一个迟迟不返回的调用上,取消请求就只能在外面排队。

1.2 “取消后还在跑”和“一取消就崩”,其实是同一个问题

我在代码评审里反复看到两种反面教材。第一种是协程捕获了CancelledError却没有重新抛出,结果任务把取消“吞掉”了,之后该干嘛还干嘛,或者直接以一个“成功”的状态悄悄结束。调用方根本等不到取消完成的信号,整个系统的预期被破坏。第二种是完全不处理取消,让CancelledError一路向上炸,导致一大片相关联的任务被连锁取消,日志刷屏,同时清理逻辑根本没有机会执行,文件、socket 连接全部泄漏。

这两种现象看起来是反的,一个“不想停”,一个“停得太粗暴”,但根源一模一样:没有把取消当作一种需要认真处理的普通控制流来看待。正确的姿势只有一个:收到CancelledError后,做你该做的清扫,然后重新抛出它,让取消这个事实继续向上传递。吞掉取消和放任不管,都是把系统的状态机搞乱。

1.3 一个被大多数人忽略的设定:CancelledError 不是普通的 Exception

很多人写代码习惯性地写except Exception,以为所有异常都会被接下来。但CancelledError继承自BaseException,它跟KeyboardInterrupt、SystemExit一样,不在普通异常的处理范围内。这意味着如果你在业务代码里用except Exception兜底,根本接不住取消信号,取消会一路穿透到任务的最外层,而你以为的“兜底保护”其实形同虚设。

反过来,这也是一件好事:你不用担心某个深埋的except Exception把取消信号误吞掉。真正需要警惕的,是显式地写except asyncio.CancelledError: pass这种代码,一旦出现,几乎肯定是 bug。我自己的习惯是在协程的最外层用except asyncio.CancelledError做一次集中处理,记录日志、回收资源,然后立刻raise把取消继续往上抛。

2. 从 cancel() 到 CancelledError:事件循环里的一次完整旅行

2.1 cancel() 执行的那一刻,发生了什么

先把最核心的行为用一段最小代码看清楚:

import asyncio async def worker(name: str) -> None: try: for i in range(1, 11): await asyncio.sleep(1) print(f"[{name}] step {i}") except asyncio.CancelledError: print(f"[{name}] got cancelled, doing cleanup...") raise async def main() -> None: task = asyncio.create_task(worker("task-a")) await asyncio.sleep(2.5) task.cancel() try: await task except asyncio.CancelledError: print("[main] task cancelled completely") if __name__ == "__main__": asyncio.run(main())

运行输出大致是:

[task-a] step 1 [task-a] step 2 [task-a] got cancelled, doing cleanup... [main] task cancelled completely

注意几个细节:cancel()调用那一刻,worker 不会立刻停下来,它会把当前这一次await asyncio.sleep(1)走完,然后在事件循环的下一轮调度里收到异常。而且,await task这一行也会跟着抛出CancelledError,因为等待一个被取消的任务,等待方同样会感知到取消状态。

还有一个容易忽略的 API 差异:在 Python 3.9 之前,task.cancel()返回None;从 3.9 开始返回bool,用于告诉你这次取消请求是否成功发出。如果你维护的老代码里有canceled = task.cancel()这种写法,注意在低版本上拿到的是None,千万别直接当布尔值用。

2.2 取消请求会在“哪一个 await 点”落下来

这里有一个非常关键的细节,很多人栽过跟头:取消请求不是立刻注入的,而是等协程下一次真正执行到 await 时才注入。如果任务还没有开始运行,那么cancel()之后,协程会在第一个 await 处直接收到CancelledError。如果任务正在运行,取消请求会作用于它当前挂起的那个 await 点。

但有个隐蔽的情况:如果协程里有一段不包含任何 await 的重计算,比如一个纯 CPU 的循环,那这段代码会一口气跑完,取消请求只能等在下一个 await 门口。这就是为什么 CPU 密集型的计算永远不应该裸放在协程里。正确的做法是把它丢进线程池或者进程池,用await loop.run_in_executor(...)等待结果,这样协程在等待期间是真正挂起的,取消信号才能及时送达。

理解了这一点,你就能回答一个经典问题:为什么我调了cancel(),任务还是把剩下的数据全处理完了?因为你的协程一直在执行不带 await 的代码,或者每次 await 都很快返回,事件循环还没来得及把异常抛进来。取消请求排着队,但协程根本没给它进门的时机。

2.3 取消状态是有“账本”的:cancelling() 与 uncancel()

Python 3.9 给 Task 增加了cancelling()方法,用来查看这个任务到目前为止收到了多少次取消请求。Python 3.11 又增加了uncancel(),可以把取消计数减回去。这两个方法在日常业务代码里用得不多,但在处理“屏蔽取消”这种特殊需求时很有价值。

一个典型的例子:你有一段清理逻辑,无论如何都想执行完,并且你希望这段清理不要被打断。常规做法是用asyncio.shield(),这个我会在后面的章节细讲。但有时候你想在finally里做一件更微妙的事——取消确实发生了,但你希望任务最终以“成功完成”而不是“被取消”的状态结束。这时候可以这样:

import asyncio async def do_something(): ... async def main(): task = asyncio.current_task() try: await do_something() except asyncio.CancelledError: # 清理结束后,通过 uncancel 让任务不以取消状态结束 task.uncancel() await critical_flush() # 注意:这里没有重新抛出 CancelledError

这种写法非常反常规,除非你有明确的理由,否则不要轻易使用。正常情况下,收到取消后老老实实清理再raise才是对的做法。uncancel()更多是为了解决标准库内部某些特殊场景,把它当成常规武器反而会制造混乱。

3. 高频踩雷场景:超时、用户中断、级联失败

3.1 wait_for 超时后为什么可能“退不干净”

asyncio.wait_for是很多人第一接触到的“取消相关 API”。你写await asyncio.wait_for(coro, timeout=5),以为超过 5 秒就会准时抛TimeoutError。但它的内部实现是:超时那一刻,wait_for会取消被包装的协程,然后继续等着个协程彻底结束,再把超时异常抛给你。

这意味着什么?如果你被包装的那个协程不配合取消,比如它在except CancelledError里没有重新抛出,而是选择继续跑完一个长任务,那么你的wait_for也会一直等下去,TimeoutError 根本不会在 5 秒时准时出现。很多人看到这里会惊觉:原来超时并不保证准时。

async def stubborn_job(): try: for i in range(100): await asyncio.sleep(1) print(f"stubborn {i}") except asyncio.CancelledError: print("stubborn received cancel, but I ignore it") async def main(): try: await asyncio.wait_for(stubborn_job(), timeout=2) except asyncio.TimeoutError: print("main: timeout raised")

这段代码里,2 秒后 wait_for 确实调用了取消,但stubborn_job吞掉了取消,继续把 100 次循环跑完。main 不会在 2 秒时打印 timeout,而是要等差不多 100 秒。所以如果你要用wait_for做严格的超时控制,一定要确保被包装的协程遵循“收到取消就退出”的约定。否则,超时保护形同虚设。

3.2 用户刷新页面 / Ctrl+C,中断信号怎么传导到任务链

另一个高频场景是请求级别的取消。比如一个搜索接口,后端要同时聚合多个上游服务的结果,用户等不及刷新了页面。此时 HTTP 框架会把请求取消信号传进来,而你正在 await 的那几个聚合协程,应当被顺势取消,避免无意义的后端消耗。

问题在于,这种取消不会自动传导。你await gather(coro_a, coro_b),如果外部取消了整个请求,只有包含这行 await 的协程会收到CancelledError,而coro_a、coro_b作为独立的 Task 并不会因此自动取消。很多人以为 gather 会“替他们管理一切”,结果每次请求超时都会泄漏出几个后台任务在那慢慢跑。

正确的姿势是:主动收集任务、逐个取消、再统一等待。一般我会写一个小的工具函数:

async def cancel_and_wait(tasks, timeout=3): for t in tasks: t.cancel() await asyncio.wait_for( asyncio.gather(*tasks, return_exceptions=True), timeout=timeout, )

这样无论取消配合与否,至少有一个兜底超时,避免进程被某个“不配合”的协程卡死。return_exceptions=True也很重要,它保证 gather 不会因为某个任务抛CancelledError而把整个等待打断。

3.3 父任务被取消时,子任务不在保险范围内

再想一个容易出事的场景:父协程创建了一个子任务,然后等待它。如果父协程被取消了,子任务会怎样?答案是:子任务完全不受影响,它会在后台继续跑。父协程在await child_task处收到CancelledError后退出,但这个child_task已经成为“被遗弃”的任务,一路跑到底。

async def child(): for i in range(10): await asyncio.sleep(0.5) print(f"child tick {i}") async def parent(): sub = asyncio.create_task(child()) await asyncio.sleep(2) async def main(): t = asyncio.create_task(parent()) await asyncio.sleep(1) t.cancel() try: await t except asyncio.CancelledError: print("parent cancelled") await asyncio.sleep(5)

你会发现child tick在父任务退出后还在继续打。这种问题在任务树比较深的时候尤其可怕:顶层任务取消,底层一堆子任务还活着,服务看似停了,实际上事件循环里还有一堆任务在跑,进程不退出,资源不释放。

Python 3.11 引入的TaskGroup在这方面做了改进:当组内任意任务失败或被取消时,TaskGroup会主动取消组内其余任务,并等待所有任务结束后才抛出异常。如果你用的 Python 版本允许,新代码我强烈建议直接用TaskGroup管理相关任务,它能帮你省掉一大半手写级联取消的功夫。

4. 资源清理的正确姿势:finally、shield 和吞掉取消的后果

4.1 清理代码怎么写才不会被取消撕碎

先记住第一条铁律:收到CancelledError后,要么在finally里做清理然后重新抛出,要么在except里做完清理立刻raise。绝对不要吞掉取消,也绝对不要用except Exception去兜底。

实操中,把清理逻辑放在finally里是最稳的:

async def business_operation(): conn = await open_connection() try: await do_work(conn) finally: await conn.close()

这段代码在任务被取消时,finally一定会执行,连接会被关闭,然后CancelledError继续向外传播。这里有个容易忽视的细节:finally里的await conn.close()本身又引入了一个挂起点。如果清理逻辑写得很长,或者清理本身需要好几秒,那么取消后的收尾时间也会很长。所以在清理函数内部,要尽量避免复杂的嵌套调用,尽量用快速的、幂等的操作。

另一个细节是:很多人误以为“取消发生时,finally 里的 await 也会立刻被取消”。实际上,取消请求是针对任务协程的,在finally块执行期间,除非你再次收到了新的取消请求,否则正常清算是能跑完的。但要注意,因为finally中的await也是挂起点,在这个挂起点上任务仍然是可被取消的。如果外部逻辑在这个窗口期又调了一次cancel(),你的清理代码就会被第二次打断。这也是为什么有些严格的代码会在清理时配合shield。

4.2 shield 不是万能护盾,但关键操作还是得靠它

asyncio.shield()的语义是:保护内部协程不被取消。当你await shield(inner_task)时,如果外层任务被取消,你会收到CancelledError,但inner_task不会收到取消,它会在后台继续运行。

这个特性特别适合用在“释放锁、提交本地事务、刷新缓存”这类关键动作上。比如下面这段:

async def do_job(): async with some_lock(): try: await actual_work() except asyncio.CancelledError: # 不想让 release 逻辑被取消打断 await asyncio.shield(release_under_lock()) raise

注意 shield 不是真正的“绝对护盾”。有两个边界你要记住:第一,如果事件循环本身已经开始关闭流程,比如asyncio.run()在主协程结束后会去取消所有剩余任务,这时候 shield 保护的协程同样可能被取消;第二,shield 只对直接await它的那一层有保护作用,如果你自己调用shielded_task.cancel(),那还是会取消内部任务。

还有一个我常用的技巧:如果你只是想让某个协程“尽量避免取消”,但不要完全脱离你视线,可以用一个中间任务加 shield 的方式来切换上下文。不过日常开发中,能遇到 shield 的场景基本就是清理和收尾,够用了。

4.3 编排清理顺序:先停入口,再撤任务,最后关资源

一个服务要优雅退出,单纯把 Task 取消掉是不够的。你得按顺序做三件事:先停止接收新的请求,再取消正在跑的任务,最后关闭共享资源。

我见过不少团队在退出时先把数据库连接池关了,结果正在跑的任务还在查库,一堆异常往外冒,日志直接炸掉。正确的顺序应该反过来。先把服务入口停下来,比如 HTTP server 停止 accept 新连接,或者消息队列消费者把订阅取消;然后给所有正在处理的任务发取消信号,等它们把自己手头的事情收尾;最后才关闭连接池、HTTP Session、文件句柄这类全局资源。

为什么这个顺序重要?因为任务在取消清理阶段,可能还需要访问这些资源来完成最后的写盘、提交、回滚。如果你提前把连接池关了,任务的清理逻辑本身就崩了,甚至可能导致数据不一致。所以记住一句话:资源是最后一道防线,一定要等任务都退场之后再关。

5. 优雅退出全链路:从 SIGINT 到最后一个协程退场

5.1 信号处理的第一道闸门:add_signal_handler

在 Linux 和 macOS 上,处理退出信号的正统做法是loop.add_signal_handler()。它能把信号回调注册到事件循环里,回调会在 loop 线程里安全执行,不会出现线程安全问题。

import asyncio import signal async def main(): loop = asyncio.get_running_loop() stop_event = asyncio.Event() for sig in (signal.SIGINT, signal.SIGTERM): loop.add_signal_handler(sig, stop_event.set) print("running, press Ctrl+C to stop") await stop_event.wait() print("stop event triggered, cleaning up...") if __name__ == "__main__": asyncio.run(main())

这里我的习惯是让信号回调只负责“设置一个 Event”,真正的退出逻辑全部集中在主协程里。不要让信号回调直接去取消任务、关资源,因为那些操作一旦写在回调里,很容易引入竞态。用 Event 作为信号到主逻辑之间的桥梁,代码会清晰很多,也更容易测试。

有人问为什么不用signal.signal()?在 Unix 上,如果你用signal.signal()注册了回调,而回调里又调用了 asyncio 相关的东西,很容易出事,因为普通的信号回调是随时可能打断事件循环主线程的。add_signal_handler则通过 event loop 内部机制来调度,回调等事件循环空闲时才执行,规避了一大类坑。

5.2 两阶段退出:温和撤离与强制兜底

真正好的优雅退出设计,通常分两阶段。第一阶段收到退出信号后,停止接收新任务,给现有任务发取消信号,然后限时等待;如果在限时内所有任务都退干净了,就正常进入资源清理阶段;如果超时了,说明有任务不配合或者卡死了,这时候进入第二阶段:强制退出。

为什么需要第二阶段?因为如果你无限期等待所有任务结束,可能会被一个卡死的协程拖住,服务永远退不干净。但如果你一收到信号就直接强退,又可能来不及保存状态。两阶段策略给任务一个“体面收尾的时间窗口”,又保证整个进程不会无限僵持。

这里的超时时间要根据业务定。我的经验是 5 到 10 秒比较常见,如果业务里有需要刷盘的写操作,可以放宽到 30 秒,但最好不要更长。等待期间用asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=grace_period)来兜底,超时后直接logger.warning记录没退出的任务,然后继续往下走资源清理流程。最终进程退出时,即使还有个别任务没干净,至少你知道它们是谁,不会留下一堆无声的泄漏。

5.3 跨平台注意点:Windows 下的信号处理差异

loop.add_signal_handler在 Windows 上是不支持的。Windows 的 asyncio 事件循环没有对应的信号机制,所以你如果直接调用,会得到NotImplementedError。跨平台代码需要做一层降级处理。

try: loop.add_signal_handler(sig, stop_event.set) except NotImplementedError: signal.signal(sig, lambda *_: loop.call_soon_threadsafe(stop_event.set))

这里用loop.call_soon_threadsafe包一层,是因为signal.signal的回调会中断当前正在执行的 Python 代码,直接在里面操作 asyncio 对象可能不安全。call_soon_threadsafe会把真正的stop_event.set()调度到事件循环线程里执行,安全很多。

如果你在开发部署在容器里的服务,还应该注意 SIGTERM 的处理。容器停止时通常会先发 SIGTERM,如果一段时间内进程没退出,才会发 SIGKILL。所以 SIGTERM 的优雅退出路径,比 Ctrl+C 的 SIGINT 路径更值得认真对待。

6. 我踩过的三个坑和一份可以直接抄的退出模板

6.1 坑一:取消后日志疯狂刷 traceback

第一次写异步批量任务时,我在每个 worker 里都没处理CancelledError,结果一到全量取消,几十个任务同时爆 traceback,日志文件一夜之间涨了几个 GB。后来才意识到,CancelledError一旦没有被捕获,会带着完整的调用栈打到日志里,而且取消是“批量”的,多个任务同时爆栈,刷屏效果相当壮观。

处理方法很简单:在协程的最外层统一捕获取消,打印一行业务可读的日志,然后raise。这样既保留了取消的事实,又不会让错误日志变成天书。如果你嫌日志太多,还可以在捕获时把日志级别降为 INFO 或 DEBUG。

6.2 坑二:进程退不出去,罪魁祸首是线程池

还有一次线上事故,服务收到退出信号后,任务都取消了,资源也关了,但进程就是不退出。排查了很久才发现,问题根本不在协程,而在线程池:有协程调了loop.run_in_executor(None, fn),把它包装成concurrent.futures.Future后,这个 Future 其实不受 asyncio 的Task.cancel()控制。你在 asyncio 侧取消它,线程池里的线程还在继续跑,事件循环也在等它。

这个问题很隐蔽,因为很多 asyncio 代码会混用线程池处理阻塞操作。解决办法有两种:一是确保你提交给线程池的任务本身是可以被中断的,比如内部定期检查一个threading.Event,外部通过事件通知线程退出;二是进程退出前,明确不要去等这些 Future,直接loop.shutdown_default_executor()强制关闭线程池。Python 3.9 之后,asyncio.run()在退出时会自动关闭默认执行器,但如果你自己创建的线程池,一定要手动收尾。

从那以后,我再写 asyncio 服务,凡是和外部 API 有同步阻塞调用,都会先问一句:这个调用底层能不能感知取消?不能的话,宁可把它包成带超时的方式,也不要放任它裸奔在线程池里。

6.3 坑三:协程明明被取消了,业务代码还在往外面写数据

最后一个坑是最近才遇到的。一个数据同步任务,主循环里在处理完一批数据后写 Kafka,取消信号来的时候,任务被取消,但下一批数据还是被写进去了。原因是协程在捕获CancelledError之后,没有立刻退出,而是在finally里又执行了一段“如果还有缓存就刷出去”的逻辑。这段逻辑本身是合理的,但它继续写了几百条消息到 Kafka,违背了“取消后不再产生副作用”的预期。

这件事给我的教训是:取消处理里要区分“收尾”和“继续干活”。收尾是清理资源、保存必要的状态,但不应该再启动新的业务动作。如果你发现自己写的取消处理里有“再处理一批数据”“把剩余队列消费完”这种逻辑,大概率是把业务逻辑误放在清理阶段了。正确做法是只做最小必要的清理,然后立刻退出。

6.4 一个可以直接抄回去改的退出模板

最后,把我现在的标准退出流程整理成一个模板,你可以直接拿回去按需改。核心思路是:信号只负责设置事件,主逻辑负责取消、限时等待、兜底清理三步曲。

import asyncio import logging import signal from typing import List logger = logging.getLogger("graceful_shutdown") async def worker(name: str, stop_event: asyncio.Event) -> None: logger.info("%s started", name) while not stop_event.is_set(): try: # 模拟业务处理 await asyncio.sleep(1) except asyncio.CancelledError: logger.info("%s cancelled, cleaning up", name) await asyncio.sleep(0.1) # 最小清理动作 raise logger.info("%s exited normally", name) async def main() -> None: loop = asyncio.get_running_loop() stop_event = asyncio.Event() for sig in (signal.SIGINT, signal.SIGTERM): try: loop.add_signal_handler(sig, stop_event.set) except NotImplementedError: signal.signal(sig, lambda *_: loop.call_soon_threadsafe(stop_event.set)) tasks: List[asyncio.Task] = [ asyncio.create_task(worker(f"worker-{i}", stop_event)) for i in range(3) ] await stop_event.wait() logger.info("shutdown signal received, cancelling tasks") for t in tasks: t.cancel() try: await asyncio.wait_for( asyncio.gather(*tasks, return_exceptions=True), timeout=10, ) except asyncio.TimeoutError: logger.warning("some tasks did not finish in time, forcing shutdown") # 到这里再关全局资源:连接池、HTTP Session、文件句柄等 logger.info("closing shared resources") if __name__ == "__main__": logging.basicConfig(level=logging.INFO) asyncio.run(main())

这个模板解决了我提到的大部分问题:信号线程安全、任务批量取消、限时等待、超时兜底。如果你要接入自己的业务,只需要把worker里的业务循环替换掉,并按顺序在“关闭共享资源”的位置关闭你的连接池即可。

写到这里,如果让我只留一个最核心的心得,那就是:把取消当成一种正常的控制流来设计,而不是把它当作“异常情况”去处理。协程内部哪些地方可以被中断、中断后做什么、哪些动作不能在取消后继续发生,这些问题在写代码之前就想清楚,比事后追着一堆诡异现象排查要省力得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询