剖析一下 CPython
的自动垃圾回收机制,并尝试提出改进的思路。
引用计数
相信有过计算机基础的人,哪怕对垃圾回收不那么熟悉,也肯定知道引用计数这个玩意。引用计数诞生于上个世纪,其主要思想是通过给每个对象增加计数,当计数为0时,则肯定没人使用该对象,可以放心将其删除。
虽然这个方法看起来有点糙,但在实际项目中,它的优点在于可以更实时的释放内存,释放内存的时机更精确,这也是为什么有的项目会尝试给 Lua
增添一个引用计数的垃圾回收,避免内存上涨过快。
凡事都有利弊,它的缺点也很明显,无法处理循环引用。
以下用 Python
举一个非常普遍的例子。
1 | class A: |
在上面中,我们手动删除了 a
和 b
,理应进行释放,但由于 a
和 b
互相构成了循环引用,导致其引用计数总是不为0,进而造成内存泄漏,而 CPython
对其解决方法也极其简单,就是将所有可能造成循环引用的对象,构成一个双向链表进行扫描,从 root object
出发进行扫描 - 清除,无法到达的对象就是可释放的对象,普通的对象直接采用引用计数去释放,简单快捷。
怎么去验证以上结论呢?我们可以用反证法,当 del a
和 del b
后,再调用 gc.collect()
查看其是否能被回收到,如果能回收到,说明在此时引用计数已经失效。
1 | # 设置 debug 标签,使得垃圾回收后的对象 存放至 gc.garbage 列表中 |
可以看出引用计数确实失效了,因为通过 扫描-清除
回收能回收到这两个对象。
1 | [<__main__.A object at 0x10adefc10>, <__main__.B object at 0x10adeff70>, {'b': <__main__.B object at 0x10adeff70>}, {'a': <__main__.A object at 0x10adefc10>}] |
接下来,我们来到 CPython
源码中查看如何用引用计数管理一个对象。我们将以整数为例,先看看整数对象的对象模型。
1 | // 对象的基类,拥有双向链表和引用计数 |
可以看出每个对象都有一条双向链表,但是这里需要说明的是,此处的双向链表并非后面扫描 - 标记所使用的双向链表,此处的双向链表会将所有的对象都链接到 refchain
中,目前从代码中只能看出是拿来做调试用途的。
1 | void |
以上关于 CPython
中的引用计数部分,就讲解完了,整体非常简单。接下来就是看容器类对象(会造成循环引用的对象)如何进行垃圾回收了。
扫描-清除
垃圾回收领域一直有几大门派,最为突出的门派分别为 扫描-清除
和 标记-整理
,先讲什么是 标记-整理
。
假设我们将语言的内存池分为两块,其中一块不用,另一块一直拿来创建对象,当垃圾回收开启时,我们将所有可达对象(即可用对象)进行标记,然后将标记的对象重新在另一块内存池中进行创建,最后直接将原本那块内存池进行释放,这就将垃圾整理完成。
标记-整理
这种垃圾回收办法依赖于一个假设,那就是垃圾对象比正常的对象要多得多,这样整理起来由于是整个内存池一起销毁的,所以会快得多。
CPython
选择的是 扫描-清除
,我们就不在其他地方进行展开了,着重来介绍 扫描-清除
。
假设我们从 root object
出发,如果可以扫描到的对象,即成为可达对象,可达对象则代表正在被使用不可清理。最终我们将得到一个不可达对象的列表,将其清理即可。
而 扫描-清除
由于扫描和清除是一次性完成的,会导致 Stop The World
时间特别长,因此产生了所谓的分代垃圾回收,这也就是 CPython
目前所使用的垃圾回收。
分代垃圾回收
分代垃圾回收基于一个假设,大部分对象存活的时间比较短,少部分对象存活的时间比较长,那么就可以优先对新生代进行垃圾回收,而对老年代的垃圾回收次数放缓,这就解决了 扫描-清除
的时间过长的问题。
接下来我们就来简单看看 分代垃圾回收的实现。我们以一个容器对象作为例子,就拿 list
好了。
以下为 list
的对象模型,由于本篇主题为垃圾回收,所以不关注其他成员。
1 | typedef struct { |
结构也是非常简单,同样有引用计数与双向链表(在 PyVarObject
结构中),那么就会有疑惑了,这里的双向链表用于链接所有对象到 refchain
,那么我们的分代垃圾回收的扫描链表去哪了?
1 | /* GC information is stored BEFORE the object structure. */ |
可以看出,在创建这类需要扫描的对象时,会提前算好头部还需要加多少内存,在头部再加一个 PyGC_Head
作为分代回收的链表,然后调用 _PyObject_GC_Link
触发垃圾回收,可以看出当创建一个对象达到该代的阈值时,将会触发垃圾回收,最后才调用 _PyObject_GC_TRACK
将其链入 第0代 GC链表
中。
1 | // Lowest bit of _gc_next is used for flags only in GC. |
从宏中可以看出,CPython
用了地址的最后两位去做一些事情,之所以可以这么做是因为内部实现了个小的内存分配器,里面的地址按4字节对齐,这意味着后两位一定为0,这也是一个常用技巧了,没什么好说的。
现在让我们关注最重要的 垃圾回收过程。
1 | static Py_ssize_t |
从最老一代开始进行收集,目前 CPython
默认有3代,分别为 0,1,2代。为了避免多次进行 full gc
,这里设置了个条件,当清理最老一代的时候,必须要 非最老一代存活的对象(long_lived_pending) / 当前最老一代存活的对象(long_lived_total) 超过 25%
才进行全量回收,其实这主要是因为 扫描-清理
过程是一次完成的,所以要尽量避免 full gc
。
接着就正式进入垃圾回收主函数。
1 | static Py_ssize_t |
在阅读之前,还要补充一个知识点,分代垃圾回收里面的三代回收是有阈值的,其中只有第0代也就是最年轻的一代的阈值指的是对象个数,剩下两代都是执行年轻代的次数。默认值为 (700, 10, 10)
这意味着想触发第0代垃圾回收需要创建出700个对象,而想触发第1代垃圾回收,需要第0代垃圾回收执行过10次,想要触发第2代垃圾回收则需要第1代垃圾回收执行过10次(同时还要满足上面的一个条件,这里就不重复了)。
1 | static Py_ssize_t |
整体来看,除了代码量略大,其他的还是很简单的,接下来我们将解决上面几个遗留问题。
- 如何找到
root object
? untrack_tuples
是个啥?untrack_dicts
为什么只在full gc
时调用?
先解释第二点,为了加快垃圾回收的迭代,当 tuple
容器没有内嵌容器时,会将其从垃圾回收跟踪中删除,只使用最基础的引用计数。证明这一点很简单。
1 | a = (1, 2) |
可以看出,对 tuple
取消追踪,是个惰性过程。接下来我们引申到 dict
。
1 | a = {"a": 1} |
可以得出,当 dict
没有复杂的对象时,则不会对其追踪,那么我们是否可以将同样的思路引用于 list
呢?
接下来我们回到问题1,如何找到 root object
?如果读者对 Lua
了解的话就知道,Lua
的对象都可以从 registry
这个全局表中追踪到,但在 Python
的世界中却是不可行的,之所以会产生这样的问题,主要还是因为 Python
扩展模块(extension modules) 工作方式导致用于无法确定根集,这就使得复杂度一下就上来了。
CPython
的解决方法也很简单,结合引用计数和扫描清除两种办法去解决。拷贝一份引用计数(如果在原本的引用计数上操作太危险了,不小心变成0,就触发了引用计数回收了),然后在其基础上进行遍历,每次将引用计数 -1,这样就得到了相对引用计数,相对引用计数为0,则有可能是不可达对象,先猜想它是,后续再遍历可达对象,如果从可达对象可以找到相对引用计数为0的对象,那么它就是可达对象,需要将其恢复。
这块虽然有点绕,但仔细品味一下还是非常简单的。
接下来我们来讨论第三个问题,为什么 untrack_dicts
只在 第三代垃圾回收时触发?
这主要是因为 dict
插入一个对象时,会判断这个对象是不是容器,是容器就会将其追踪,但是每次都会在 untrack_dicts
去遍历检查是否可以取消追踪,这就很蠢了,有兴趣的可以阅读 Issue #14775。
其实还有些内容想讲,随便来个话题,在 Python 2
时代,当两个对象循环引用又同时有 __del__
时,垃圾回收会不回收这两个对象这类问题,但我不想在这里继续展开了,太累了,有兴趣可以阅读 PEP-442 进行学习。
想法
渐进分代?
CPython
的GC是 Stop The World
的,哪怕它已经很尽力用分代的方式去减少GC的损耗。是否可以将其改进为渐进的方式?我目前的想法是在容器操作时,进行 Barrier
操作,维护一个中间态,使得前面的扫描过程是可渐进的,最后处理垃圾的时候再停下来一次性处理完,减少停止的时间。但这个思路貌似不行,原因是根集是不确定的。
减少跟踪对象?
是否可以对其它常用容器也做 untrack
操作,当容器没有嵌套容器时,取消 track
操作,减少GC遍历损耗?这个思路需要小心避免犯上面 untrack_dicts
的错误。
甚至我们扩展 gcmodule
的接口,使得对一些常驻内存的对象进行标记,使其不要被跟踪?
尽可能少用 del
这点就不说啥了,Lua
里也最好别用 __gc
。
最后的最后,感谢 CPython
这份非常漂亮的代码设计,让我在这个五一假期,受益良多,下一步可能会回到 Lua 5.2
中,阅读它 “失败” 的分代GC作品,我认为学习失败的经验比成功的经验要重要得多。