一、引言:Python 也会"内存泄漏"?

很多人对 Python 的印象是"不用管内存"。这话对了一半。

Python 确实没有 C++ 那种"忘了 delete 就泄漏"的日常负担,底层靠**引用计数(reference counting)**在对象不再被引用时立刻回收。但引用计数有一个它自己解决不了的死角:循环引用。两个对象互相指着对方,引用计数永远归不了零,计数机制就失效了。

所以你会在真实项目里看到这种诡异现象:一个处理完的请求对象明明"没人用了",进程 RSS 却缓缓往上爬,爬到几 GB 才被周期性 GC 清掉,严重的甚至一直爬——这就是循环引用漏网。本文把这套机制讲透,并和 C++ 的内存管理正面比对。

1
2
3
4
5
6
7
8
9
10
11
12
import tracemalloc, gc
tracemalloc.start()
# 模拟一个"看似释放、实则泄漏"的请求上下文
class Ctx:
pass
def handle():
a = Ctx(); b = Ctx()
a.peer = b; b.peer = a # 互相引用,形成环
return
handle()
# 若从不触发分代 GC,这两个 Ctx 会一直占着内存
print("当前未释放字节数(示意):", tracemalloc.get_traced_memory()[0])

二、核心概念:引用计数 ob_refcnt

CPython 里每个对象头部都有个计数器 ob_refcnt。引用 +1、解引用 -1,归零就立即调用析构并释放。它是确定性的——对象"死"的时机你基本能算出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
import sys

class Node:
pass

a = Node()
print(sys.getrefcount(a)) # 2: a 自身 + getrefcount 入参的临时引用
b = a
print(sys.getrefcount(a)) # 3: 多了一个 b
del b
print(sys.getrefcount(a)) # 2: 回到 2
del a
# 此处 ob_refcnt 归零,Node 实例被立即回收

引用计数的内存布局(示意):

1
2
3
4
5
6
7
8
9
10
11
12
对象 Node
+-------------------+
| ob_refcnt = 2 | <-- a、b 各持一个引用
| type = Node |
| data ... |
+-------------------+
^ ^
| |
a b

del b 后: ob_refcnt = 1 (只剩 a)
del a 后: ob_refcnt = 0 --> 立即析构 + 释放

C++ 对照:C++ 没有引用计数(除非你用 std::shared_ptr)。裸指针 Node* p = new Node; 得手动 delete p;,忘了就泄漏,多 delete 一次就双重释放,悬垂指针更是经典坑。C++ 的"确定性析构"靠 RAII——对象离开作用域,析构函数立刻跑。Python 的引用计数路径也有这种确定性,但 GC 路径没有(见下)。


三、深入:循环引用为何漏网,以及分代 GC 怎么补

3.1 计数归不了零的环

1
2
3
4
5
6
7
8
9
10
11
12
13
import gc
gc.disable() # 先关掉自动 GC,看计数 alone 的盲区

class Node:
def __init__(self):
self.peer = None

a = Node(); b = Node()
a.peer = b; b.peer = a # a<->b 互指
print("创建环后, 各代待处理计数:", gc.get_count()) # 例如 (2, 0, 0)
del a; del b # 外部引用都删了
# 但 a.peer 指着 b、b.peer 指着 a,彼此 ob_refcnt 都还是 1
print("外部引用已删, 但对象仍在:", gc.get_count()) # 计数没归零

环的内存布局:

1
2
3
4
   a <-----------> b
| |
peer peer
(各持对方 1 个引用,ob_refcnt 永远 >= 1)

引用计数看到的是"还有人指着",于是两个对象谁也不回收——这就是漏网

3.2 分代 GC 来兜底

CPython 的 gc 模块跑一套分代、追踪式(tracing)垃圾回收:把对象分 0/1/2 三代,新对象进第 0 代;每代有一个分配/回收的阈值,触发后从根集(栈、全局变量等)出发做可达性分析,凡是"从根集出发怎么走都走不到"的对象,不管有没有环,一律回收。

1
2
3
4
5
import gc
gc.set_debug(0)
# 接上一节的环(a、b 已 del,但互相引用)
n = gc.collect() # 手动触发一次全代回收
print("本次回收的不可达对象数:", n) # 2:a、b 被成功清掉

关键点:追踪式 GC 不依赖计数归零,而是看"还能不能从根到达"。环里再怎么互指,只要外部没人能到达它们,就判定为垃圾。

3.3 __del__ 的陷阱

如果环里的对象带 __del__(析构方法),且 GC 无法确定安全的回收顺序,旧版本会把整个环丢进 gc.garbage不自动回收——轻则泄漏,重则你还得手写清理逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
import gc

class Bad:
def __del__(self):
pass

a = Bad(); b = Bad()
a.other = b; b.other = a
del a; del b
n = gc.collect()
print("含 __del__ 的环, 本次回收数:", n) # 可能为 0
print("进 garbage 待手动处理的环:", gc.garbage) # 非空

现代 Python(3.4+)已能尝试给带 __del__ 的环定序回收,但不可靠。工程上最佳实践是:别在会成环的对象上放 __del__,或改用 weakref 打破环(见下节)。


四、实战:用 weakref 打破循环 + 排查泄漏

4.1 weakref:引用但不计数

弱引用(weak reference)指向对象,但不增加 ob_refcnt。用它连环,环就不再是"强引用环",GC 能正常回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
import weakref, gc

class Node:
def __init__(self, name):
self.name = name
self.peer = None

a = Node("A"); b = Node("B")
a.peer = weakref.ref(b) # 弱引用:不增加 b 的计数
b.peer = weakref.ref(a)
print("a 通过弱引用访问 b:", a.peer().name) # A 仍能找到 B
del a; del b
print("弱引用环回收的不可达对象数:", gc.collect()) # 2:干净回收

4.2 真实排查套路

1
2
3
4
5
6
7
8
9
10
11
12
import gc

# 1) 打开调试,能看到回收详情
gc.set_debug(gc.DEBUG_LEAK)

# 2) 手动兜底回收(长生命周期服务里常放在定时任务)
collected = gc.collect()

# 3) 定位"哪些类型最容易成环残留"
from collections import Counter
types = Counter(type(o).__name__ for o in gc.get_objects())
print("当前存活对象按类型计数(节选):", types.most_common(5))

更进一步的图形化排查可用 objgraphobjgraph.show_backrefsobjgraph.count())直接画出"谁还拽着这个对象不放",比盲猜高效得多。

4.3 一个匿名化的真实踩坑

某次排查一个常驻服务的内存缓涨:一个"请求上下文"对象里挂了个回调列表,回调又闭包捕获了上下文自身,于是 ctx -> callbacks -> ctx 成环。上下文本该随请求结束释放,结果堆积到数万条才被 GC 周期性清掉。修法是把回调对上下文的引用改成 weakref,环断,内存曲线立刻平稳。要点:只要对象会"自己指着自己这一族",就先想 weakref。


五、对比总结:C++ vs Python,计数 vs 追踪 vs 分代

维度 C++(裸指针 / RAII) Python(引用计数 + 分代 GC)
释放时机 手动 delete / 离开作用域确定性析构 引用计数即时 + 分代 GC 周期兜底
循环引用 无 GC,须手动断环或用 weak_ptr 分代 GC 自动收不可达环
确定性析构 有(作用域结束即析构) 仅 refcount 路径确定;GC 路径不确定
典型坑 悬垂指针、双重释放、遗忘泄漏 循环引用泄漏、__del__ 环、gc 暂停引起卡顿
排查工具 Valgrind、ASan、Heob gc 模块、objgraphtracemalloc
心智负担 高(要自己管每一块) 低,但需懂"环"这一死角

一句话收束:引用计数解决 99% 的日常释放,分代 GC 专门收拾那 1% 的循环引用残局;写 Python 不必手动管内存,但只要对象会"互相抱着",就得记得 weakref 这把剪刀。