引用计数、循环回收器与不断增长的那一行
一句话总结
CPython 对大部分对象通过引用计数当场清理,引用计数清理不了的循环,则由分代收集器偶尔集中清理。这个“偶尔”如果恰好落在一个请求中间,平均值不变,p99 却会飙升。内存增长的问题,要到“增长最多的那一行”,而不是“最大的那一行”里去找。
为什么需要它
“CPU 与内存泄漏的判定”课程在进程外部,通过 RSS 和 Private_Dirty 的斜率来抓泄漏。这种方法能告诉你“正在泄漏”,却不能告诉你“是哪一行”。“堆还有空间,服务却停了”课程则通过 JVM 的 GC 日志来读停顿时间。Python 服务也有同样的两个问题:延迟长尾的百分之多少是 GC 造成的,以及不断增长的内存来自源码的第几行。这两个问题,只用标准库就能用数字回答。
工作原理
引用计数与循环收集器。 gc 模块文档写道,这个收集器是对 Python 已经在使用的引用计数的补充,所以如果确信不会产生循环引用,可以把它关掉。父节点持有子节点列表、子节点又指向父节点的树,即使请求结束,也会互相抓住对方,引用计数不会归零。只有这种循环才归收集器管。CPython 的内部文档解释说,收集器只跟踪能够容纳其他对象的容器对象。
分代与阈值。 同一份 gc 文档写道,对象被分为三代;当自上次收集以来的分配数减去释放数超过 threshold0 时,先检查第 0 代;第 0 代的检查次数超过 threshold1 次时,也会检查第 1 代。正如内部文档所说,最老的一代只有在长寿对象中新进入的部分超过 25% 时,才会做完整收集。默认阈值随版本而不同。在本实验镜像(3.12.3)中打印 gc.get_threshold(),会得到 (700, 10, 10),而内部文档的最新版把默认构建的初始值写为 (2000, 10, 10)。所以不要背数字,要自己打印出来看。关键在于,收集是由“分配数”而不是“时间”触发的。制造循环的 handler 只分配、不释放,会频繁越过阈值。
测量收集耗时的方法。 把函数放进 gc.callbacks,它会在收集之前以 phase "start"、之后以 "stop" 被调用,info 中包含所收集的代(generation)和回收的对象数(collected)。start 与 stop 之间的时间,就是这次收集造成的暂停。把它与逐个请求测得的延迟并排放在一起,就能回答“p99 为什么飙升”。用材料中的模拟服务在这台 Pod 上测量,2 万个请求期间收集运行了 300 多次(约占请求的 2%),与断开循环的 handler 相比,p50 差不多,p99 却上升了好几倍。如果超过 1% 的请求承担了收集,这部分开销就会原样体现在 p99 上。如果把整个请求时间记为 GC 时间,这个因果关系就消失了。
gc.freeze。 按文档所述,gc.freeze() 会把当前正在跟踪的所有对象移入永久代,使之在以后的收集中被忽略。文档举出的用途是 fork 之前——在父进程中尽早 gc.disable(),fork 之前 gc.freeze(),在子进程中尽早 gc.enable(),这样子进程的收集就不会碰从父进程继承来的老对象,由 copy-on-write 引起的复制会减少。启动时创建的大型静态数据在每次收集时被重新遍历的开销,也会一并省掉。
哪一行在增长。 tracemalloc 提供内存块的分配位置以及按文件、行的统计,并能计算两个快照之间的差值,帮你找出泄漏。Snapshot.compare_to(old, "lineno") 会按 size_diff(增加的字节数)绝对值从大到小排序后返回。单个快照的 statistics() 最上面是“占用最多的那一行”,所以启动时创建的那个大表会排在最前面。泄漏是在增长,所以先放少量请求进去预热,再拍一次快照,再多放一些后再拍一次,看两者之差。文档写道,保存的帧越多,tracemalloc 自身的内存和 CPU 负担也越大。
浅层大小的陷阱。 sys.getsizeof 只统计直接附属于对象的内存,不统计该对象所指向的对象。一个字典的 getsizeof 不包含键和值的大小,所以要知道整个容器的大小,就得像官方文档链接的递归配方那样顺着往下走,同时不能把同一个对象算两次。slots 一节写道,实例默认带有用于存放属性的字典,对只有几个变量的对象来说是浪费,可以用 __slots__ 减少这部分空间。但在这台 Pod 上实测,getsizeof 反而会把 slots 一侧显示得更大。这意味着 getsizeof 测量的东西与实际分配的东西不同,所以比较要通过 tracemalloc 创建 10 万个对象再相除来得到。
缓存变成泄漏的时候。 用不会再次出现的键来填充的全局字典,如果没有上限,就只是泄漏。要么设定上限、从最旧的开始丢弃,要么用 weakref 持有值一侧的引用,让它在别处不再使用时消失。weakref 文档写道,仅靠弱引用无法让对象活下去,而盛放大对象的缓存是主要用途。不过文档也写道,list、dict 直接不能被弱引用,int、tuple 即使子类化也不能,所以要先看里面放的是什么。像父指针这样制造循环的反向引用,改成 weakref 后循环就消失了。
在现场相遇的样子
JVM 一侧,“堆还有空间,服务却停了”课程用 GC 日志和堆转储来讲,进程外部的泄漏斜率由“CPU 与内存泄漏的判定”课程讲。如果是事件循环服务器,GC 暂停也会像阻塞事件循环的调用一样,让所有请求一起变慢——“慢的不是一个请求,而是全部”课程中的事件循环延迟观测,在这里同样适用。
下一项实验要做什么
打印这台 Pod 的 GC 阈值,数出制造循环的 handler 留下的垃圾,再用 weakref 断开循环。用 gc.callbacks 测量收集耗时并与 p99 联系起来,看看 gc.freeze 能把完整收集减少多少。比较 getsizeof、深层大小和 slots,然后用 tracemalloc 找出泄漏的那一行,用有上限的缓存修好,并确认增长消失了。