内存涨上去了,很多人第一反应是「手动 gc.collect() 一下」。但它只处理一种问题:不可达的循环垃圾。这篇拆开四种「gc 帮不上忙」的情形,每种都是真实测量。
往全局 cache 里放了 20000 个对象后, Python 占用 24.3 MB; gc.collect() 回收了 0 个对象, 之后仍占用 24.3 MB gc.get_referrers(cache[123]) 找到的持有者: ['dict (长度 20000)'] del cache 之后 gc.collect() 回收 0 个, 占用 0.0 MB (释放的是引用, 不是靠 gc)
全局字典里放了 20000 个对象,占 24.3 MB。gc.collect() 回收 0 个——这些对象是「可达」的,gc 无权动它们。gc.get_referrers(某个对象) 直接指出持有者是一个长度 20000 的 dict。del cache 之后内存立刻归零,靠的是引用计数,不是 gc。所以排查内存涨的第一步是问「谁还引用着它」,而不是「gc 为什么没回收」。
创建 5000 个自引用 Blob 之后, 数量增加最多的类型: {'Blob': 5000}创建 5000 个 Blob,每个带一个 1000 字节的 bytearray。类型直方图里只增加了 Blob: 5000,占了大部分内存的 bytearray 根本没出现——它不会引用别的对象,所以不被 gc 追踪,gc.get_objects() 里没有它。用这个方法排查,会漏掉「不可能成环但很大」的东西;需要结合 tracemalloc 看真实分配。
基线 RSS 54.6 MB → 创建 20 万个 (各带 1000 字节 payload, 自引用) 280.1 MB → gc.collect() 回收 199999 个对象后 246.2 MB 对照: 申请一块 300 MB 的 bytearray 并写满 546.3 MB → del 之后 546.3 MB
创建 20 万个对象(每个带 1000 字节 payload),RSS 从 55 MB 涨到 280 MB;gc.collect() 回收了 199999 个对象,RSS 只回到 246 MB。对照实验里,一块 300 MB 的 bytearray del 之后,ps 看到的 RSS 也没有下降(546 → 546 MB)。这是在 macOS 上的结果——底层内存是否立即归还给操作系统取决于分配器和系统策略。所以「监控里内存没降」不能推出「Python 没释放」;要看的是 Python 层的占用(tracemalloc)。
活着 10,000 个被追踪对象时, 一次完整 gc.collect() 用时 0.58 ms 活着 100,000 个被追踪对象时, 一次完整 gc.collect() 用时 1.60 ms 活着 1,000,000 个被追踪对象时, 一次完整 gc.collect() 用时 12.02 ms
完整回收要遍历所有被追踪的存活对象:1 万个时 0.58 ms,100 万个时 12.0 ms。如果在循环里频繁手动调用 gc.collect(),程序里存活对象越多,每次的暂停就越长。
100 万个存活对象: freeze 前 gc.collect() 12.05 ms → gc.freeze() 之后 0.001 ms; gc.get_freeze_count() = 1,009,146 gc.unfreeze() 之后 gc.get_freeze_count() = 0
gc.freeze() 把当前所有对象放进永久代,之后的回收不再遍历它们:100 万个存活对象,回收从 12.05 ms 降到 0.001 ms;unfreeze 后数量归零。适合长期不变的大缓存、或者 fork 之前。冻结的对象里如果后来产生了垃圾,它们也不会被回收——这条我没有单独验证,仅从「移出回收范围」的定义推出。
gc、tracemalloc;RSS 用 ps -o rss= 测得gc.set_debug 与 gc.garbage、多进程 fork 后写时复制的实际节省量