用 objgraph 定位 asyncio 协程泄漏
处理 asyncio 协程泄漏时,objgraph 是一个非常好用的工具。它能帮你直接看到哪些协程对象没有被清理,以及它们为什么还留在内存里。不过,它不会自动告诉你那些是“泄漏”的——你得自己动手查引用链,才能把问题从代码堆里捞出来。

objgraph 能看到哪些协程对象没被回收
协程对象(coroutine)和任务(asyncio.Task)一旦启动,如果没有及时 await 或 cancel,就可能成为内存里长期驻留的“钉子户”。objgraph 不会自动帮你过滤出活跃协程,它只记录引用关系——所以你需要主动筛选那些状态是 PENDING、却没有被任何变量引用、同时也没被事件循环清理的 Task 实例。
常见的驻留对象主要有这几类:
asyncio.Task:尤其是用asyncio.create_task()启动后,既没有 await 也没有加异常处理,任务就会一直挂在那儿。- 被闭包捕获的协程函数内部变量:比如你在
async def handler(): ...里定义了一个大字典,而它正好被返回的Task引用了——一旦任务不释放,字典就跟着常驻内存。 - 未清理的
asyncio.Queue或asyncio.Event,它们经常被协程长期持有引用,导致对象链无法释放。
用 objgraph 抓住泄漏源头的三步操作
很多人一上来就想直接用 objgraph.show_growth(),但这个方法对协程其实不太敏感。正确的做法是结合时间点快照和引用链追踪来定位。
推荐按下面这步流程操作:
- 在怀疑泄漏之前,先调用一次
objgraph.take_snapshot()记录 baseline。 - 然后执行若干轮异步逻辑(比如模拟 100 次 HTTP 请求),再调用一次
objgraph.take_snapshot()。 - 用
objgraph.get_diff()看看新增了多少Task、coroutine、frame。如果Task数量持续增长,而len(asyncio.all_tasks())不降,那基本可以确认存在泄漏。
接着针对新增的 Task 实例,用 objgraph.find_backref_chain(task, objgraph.is_proper_module) 往上追溯引用链。顺着它找上去,大概率能看到某个未 await 的 create_task() 调用点,或是一个忘了 await queue.join() 的消费者协程。
为什么 asyncio.current_task() 和 task.cancel() 有时不管用
你可能会遇到这种情况:明明调用了 asyncio.current_task() 或 task.cancel(),但内存泄漏依然没解决。这背后有几个原因值得注意。
asyncio.current_task() 并不总能拿到所有 Task,尤其是当它运行在非当前事件循环线程、或者已经被移出任务集但引用未断的情况下。更关键的是,task.cancel() 只是在任务里设了一个取消标志,真正的清理需要协程体内部捕获 CancelledError 并释放资源。
典型的管不着的场景:
- 协程里用了阻塞调用(比如
time.sleep()),导致CancelledError无法及时抛出。 - 协程卡在
await asyncio.Lock.acquire()的等待状态,cancel 后依然留在等待队列里。 - 任务被
asyncio.create_task()启动后直接丢到后台,既没保存变量也没有 await,后面连task.cancel()都找不到入口。
在这种情况下,objgraph 是唯一能告诉你“这个 Task 还活着,而且它的 cr_frame.f_locals 里还攥着一个 50MB 的 response_data” 的工具。
避免误判:协程对象本身不等于内存泄漏
这里需要特别提一下:刚刚创建的 coroutine 对象是惰性的,不执行就不会分配栈帧,也不能算泄漏。如果在 objgraph 里看到大量 coroutine 类型,先通过 coro.cr_running 或 coro.cr_await 判断它是否真的在运行或挂起。否则,有可能把那些临时生成、即将被 GC 回收的协程也当成问题。
真正应该盯紧的是:
Task实例数量持续上升(对比len(asyncio.all_tasks()))。- 某个
Task的_state是PENDING,但它的cr_frame显示卡在await asyncio.sleep(3600)这类长延迟点上。 - 通过
objgraph.show_backrefs([task], max_depth=3)发现,它被一个全局dict或 class instance 意外强引用着。
协程的生命周期短,调度又频繁,靠人工看日志很难把上下文串起来。objgraph 当然不是万能工具,但它在定位泄漏时给出的引用路径,往往就是那个忘了加 finally: lock.release() 的两行代码的位置。