GC 机制探究之 Python 篇

1,341 阅读15分钟

本文首发于 at7h 的个人博客

本文主要讨论垃圾回收(Garbage Collection)机制在 Python 3.7 中是如何工作的。

不同的 Python 解释器有各自 GC 的实现方式,比如 Jython 跑在 JVM 上,采用了标准的 Java GC,Pypy 的 GC 使用了标记-清除(Mark and Sweep)算法,它比 CPython 的更加复杂并且有着一些额外的优化。 本文讨论主流的 CPython 解释器

通常,你在使用一些高级语言(Python、Golang、Java 等)编写代码的时候,一般无需关心内存管理的问题,当对象不再需要的时候,GC 会自动的把它们回收。但是了解 GC 的工作原理可以帮助你编写更好、更快的程序,帮助你排查一些复杂的内存问题。

常见 GC 算法

业界常见的比较知名的垃圾回收算法有:

1. 引用计数(Reference counting):对每个对象维护一个引用计数,当引用计数器为零时回收该对象。代表语言如 Python。

  • 优点:简单有效,对象可以被很快回收,不会出现内存耗尽或达到某个阀值时才回收。
  • 缺点:很明显,对每个对象维护引用计数有一定代价,而且致命的是无法处理循环引用的问题。

2. 标记-清除(Mark and Sweep):为解决循环引用问题而提出,算法从根对象开始遍历程序所有对象的引用,可达的对象被标记,最终没被标记的对象被删除回收。代表语言如 Golang(三色标记)。

  • 优点:解决循环引用的问题,并且在算法执行期间不会产生额外的开销。
  • 缺点:需要 STW(stop the world),即执行期间需要程序暂停,并且容易在经过多次『标记-清除』循环后导致内存碎片化。

3. 停止-复制(Stop-and-Copy) :与标记-清除算法类似,差异在于如何处理这些可达的存活对象。该算法将整个堆空间被切分活动区和空闲区,垃圾收集时将所有的可达的存活对象复制到另一区,原本半区的就可以被回收,程序会在新的活动区中分配内存。

  • 优点:运行高效且不容易产生内存碎片,基本解决了标记-清除内存碎片化的问题。
  • 缺点:需要 STW,收集器必须复制所有的活动对象,这增加了程序等待时间,并且在同一时间内只能使用整个内存空间的一半。

3. 标记-压缩(Mark-and-Compact) :标记阶段与标记-清除算法相同,压缩(整理)阶段不是直接清理,而是让所有的对象都向一端移动,按顺序排放更新对应的指针,然后清理掉端边界以外的内存。

  • 优点:避免了标记-清除的碎片化问题,同时也避免了停止复制算法的内存空间减半问题。
  • 缺点:需要 STW,低效,因为增加了 copy 和更新指针的过程。

5. 分代收集(Generational Collection):与其说是一种新算法不如说是一种优化策略,目前很流行。算法按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,短的放入新生代,从而区别不同的回收算法策略和频率,让各算法充分发挥优势。代表语言如 Java。

  • 优点:可结合其他检测算法,回收性能好。
  • 缺点:算法比较复杂。

没有绝对好和坏的解决方案,在真正语言实现中一般都是几种算法结合使用,下面主要来探讨 Python 中的垃圾回收策略。

GC in Python

在 Python 中,一切皆对象,知道它们什么时候被分配内存很容易,当你需要的时候你可以很容易创建一个新的对象。但回收确是比较复杂的一件事,需要确定你的对象确实是不再需要了,过早的清理可能会导致程序崩溃,如果不予以释放,则会造成内存泄露。

垃圾回收的主要任务就是确定哪些对象可以被收回,并选择一个最佳的时间来释放它们占用的内存。标准的 CPython GC 有两个组件:

  • 引用计数收集器(reference counting collector):主要的、基础模块、不可控,不能禁用。
  • 分代垃圾收集器(generational garbage collector):辅助的、可以控制,即 gc module

CPython 的垃圾回收主要是通过引用计数技术,引用计数是非常有效和简单的,但它不能检测循环引用(reference cycles),因此 CPython 又设计了一个分代垃圾收集器作为补充算法来专门处理循环引用的问题.因它只处理循环引用,也被称为分代循环(generational cyclic) GC。Python 中常说的 GC 其实就是它,其默认情况下是启用的,你可以手动触发和禁用它。

CPython GC 的策略是:

  1. 对每个对象维护引用计数
  2. 通过一个辅助算法来定期检测循环引用,释放无用对象
  3. 引入分代策略来优化此检测,提高性能

引用计数

引用计数是一种简单的技术,当程序中没有对该对象的引用时,将其释放。

Python 中的每个变量都是对真实对象的引用(指针),为了跟踪引用,每个对象(包括整数、字符串)都会有一个引用计数的额外字段,该字段会一直维护,在创建或删除指向该对象的引用时会增加或减少。有关详细说明,可以参见 Objects, Types and Reference Counts 部分。

导致引用计数增加主要有:

  • 赋值运算符
  • 参数传递
  • 将对象(作为元素)append 到容器中

如果对象的引用计数达到零,CPython 会自动调用对象特定的内存释放函数。如果一个对象包含对其他对象的引用,则它们的引用计数也会自动减少,因此,可以依次释放其他对象。例如,当删除 List 时,所有 List 元素的引用计数都会减少。如果存在另一个变量引用了 List 中的某个项目,则该元素不会被释放。

在 Python 中,全局变量会一直存在直到 Python 进程结束为止。因此,由全局变量引用的对象的引用计数永远不会降为零,所有全局变量都存储在字典中,你可以通过调用 globals() 函数来获取它们。

在某一个『块』中定义的局部变量,如 functionclasswith(context's enter/exit) 语句具有局部作用域,当解释器从该块中退出时,会释放在该块内部创建的局部变量及其引用。在 Python 中,用的最多的『块』应该就是函数(方法)了,这是大多数垃圾收集发生的地方,同时这也是保持函数功能单一一个原因吧。

在 Python 中,你始终可以使用 sys.getrefcount 函数检查对象引用的数量。来看一个简单的示例:

In [1]: import sys

In [2]: sys.getrefcount?
Docstring:
getrefcount(object) -> integer

Return the reference count of object.  The count returned is generally
one higher than you might expect, because it includes the (temporary)
reference as an argument to getrefcount().
Type:      builtin_function_or_method

In [3]: l = []

In [4]: sys.getrefcount(l)
Out[4]: 2 # 变量 l 和 getrefcount 中的临时变量

In [5]: def f(a):
   ...:     print("Here reference count is:", sys.getrefcount(a))
   ...:

In [6]: f(l)
Here reference count is: 4

In [7]: sys.getrefcount(l) 
Out[7]: 2 # 函数作用域的被销毁

有时我们需要刻意的手动删除某一变量,可以使用 del 语句(其删除变量及其引用,而不是对象本身),这在 REPL 环境(Jupyter notebooks,IPython 等)工作时通常很有用,因为所有变量都是全局范围的。

个人觉得,CPython 使用引用计数主要是历史原因,关于这种技术的弱点很多争论。很多人觉得现代垃圾收集算法可以更高效,无需引用计数,引用计数算法有很多问题,例如循环引用,线程锁定以及内存和性能的额外开销。

引用计数应该也是 CPython 无法摆脱 GIL 的原因之一。

循环引用

以下情况会产生循环引用:

  1. 对象包含本身的引用
  2. 两个对象之间相互引用

为了证明这一点,我们使用 ctypes 模块来跟踪访问对象的内存地址,以展示上面两种情况:

In [1]: import gc, ctypes

In [2]: gc.disable()

In [3]: class Object(ctypes.Structure):
   ...:     _fields_ = [("refcnt", ctypes.c_long)]

In [4]: l = []
   ...: l.append(l)

In [5]: lst_address = id(l)
In [6]: del l

In [7]: object_1 = {}
   ...: object_2 = {}
   ...: object_1['obj2'] = object_2
   ...: object_2['obj1'] = object_1

In [8]: obj_address = id(object_1)
In [9]: del object_1, object_2

In [10]: Object.from_address(obj_address).refcnt
Out[10]: 1

In [11]: Object.from_address(lst_address).refcnt
Out[11]: 1

示例中,del 语句删除了对象的引用,对象被 del 后,我们不能再从 Python 代码中访问这些对象,但它们仍位于内存中。在 Python 2.0 版本以前,它们在程序运行中永远不会被释放。发生这种情况就是因为它们仍然相互引用,并且每个对象的引用计数为 1,感兴趣的同学你可以使用 objgraph 模块来直观地探索这种对象关系。

解决循环引用问题

为了解决循环引用问题,CPython 引入了一种附加的循环引用检测算法来负责处理这样的问题(即自 Python 2.0 开始的 gc 模块)。

那么问题来了,为什么当时不采用上文说的几种传统回收算法(如 Mark and Sweep 或 Stop-and-Copy 等)呢?原因是由于 CPython 扩展模块的工作方式,CPython 永远无法完全确定根集,这样就有可能释放仍然在某个地方有引用的对象,造成程序崩溃。所以最终是在引用计数的基础上,设计了一种可以检测并处理循环引用的算法。

提示:如果你使用搜索引擎搜索过相关的文章,你可能会发现很多文章都讲『Python 是以引用计数为主,标记-清除和分代收集为辅的』收集策略,这是错误的,CPython 中使用的循环引用检测算法(下一节中介绍)并不是标记-清除。使用标记-清除算法的是 Pypy 的 GC。

从概念来说,CPython GC 与上述的几种算法刚好相反,它试图找出不可达的非活动对象,CPython 的开发者认为这将更加安全,即使算法失败,状况也不会比没有垃圾回收的情况更糟(除了浪费的时间和空间)。(就是感觉并不是那么自信 😂

因为循环引用只会在容器类对象中发生,所以只需专注于跟踪所有的容器对象,而且,某些情况下的的容器对象也是不会产生循环引用的(例如只包含不可变对象的字典),所以对于这一类的对象可以进行取消跟踪的优化,取消跟踪一般会在以下两个时机进行:

  1. 创建容器对象时
  2. 容器对象被收集器检查时

CPython GC 检测算法不会跟踪除元组以外的所有不可变类型,并且作为优化策略,只包含不可变对象的元组和字典也在某些条件下被取消跟踪(其中元组对象是在 GC 过程中决定是否继续跟踪,而字典对象会在创建和 GC 过程中决定)。

我们可以使用 gc.is_tracked(obj) 函数来查看对象的跟踪状态。看几个例子:

In [1]: import gc

In [2]: gc.is_tracked(1)
Out[2]: False

In [3]: gc.is_tracked('s')
Out[3]: False

In [4]: gc.is_tracked([])
Out[4]: True

In [5]: gc.is_tracked({})
Out[5]: False

In [6]: gc.is_tracked({'s1': 1})
Out[6]: False

In [7]: gc.is_tracked({'s1': []})
Out[7]: True

再来看一个对于字典和元组对象优化的例子:

In [8]: d = {'s': 1}

In [9]: gc.is_tracked(d)
Out[9]: False

In [10]: d['l'] = []

In [11]: gc.is_tracked(d)
Out[11]: True

In [12]: t = (1,2,3)

In [13]: gc.is_tracked(t)
Out[13]: True

In [14]: gc.collect()
Out[14]: 1015

In [15]: gc.is_tracked(t)
Out[15]: False

如何找到循环引用?

关于 CPython GC 检测循环引用的具体算法逻辑,很难用几段话解释。其主要是维护了两个双向链表,一个包含所有要扫描的对象,另一个包含『暂时』无法访问的所有对象。每个跟踪的容器的对象都有额外的 2+1 个字段,即两个链表指针和一个 gc_refs 字段(初始值为该对象的引用数)。然后寻找循环引用的大致思路是,遍历所有要扫描的容器对象,对于每个容器对象,找到它引用的所有容器对象,并减小其 gc_refs 值,经过完全迭代后,所有引用计数小于 2 的对象都再无法从 Python 访问,因此可以将其收集。

为了完全理解循环引用查找算法,建议感兴趣的同学继续阅读 dentifying reference cycles 一节,或从 CPython 的源码中查看 collect 函数。

使用分代策略优化

CPython GC 有了上述循环引用检测算法,已经可以检测并处理掉程序中循环引用的对象了。接下来就需要关心性能问题了,因为它是定期运行的,所以 CPython 的开发者为此也引入了启发式的分代策略。

为了使收集花费的时间尽可能短 ,CPython GC 将容器对象分为三代,每个新建的对象都归于最年轻第一代,如果该对象在一次垃圾收集中幸存下来,没有被释放,那么它将被移至较高(老)的一代——第二代。与高(老)世代相比,较低(年轻)世代的垃圾收集频率更高,所以大多数临时对象(局部变量等)的创建和清理都是很快的,由此提高了 GC 的性能减少了 GC 的暂停时间。

为了决定何时执行 GC,每个世代都有一个单独的计数器阈值,计数器存储当代自上次垃圾收集以来分配对象数量减去被释放对象数量的差值,这得益于算法对容器对象的跟踪。每当你分配一个新的容器对象时,CPython 都会检查第一代计数器的值是否超过阈值。如果超过阈值,则在第一代中启动一次 GC(运行上述算法),那些不会被使用的对象将会被释放,其余被移至第二代,更新计数器,以此类推。

当前各代的计数器值可以通过 gc.get_count() 查看。标准阈值内置为(700, 10, 10),你可以使用 gc.get_threshold() 查看, 还可以使用 gc.get_threshold 函数进行调整。
此外,你还可以通过 gc.collect(generation=None) 手动触发 GC。

如果你有兴趣,可以继续在 CPython 的源码中查看 collect 函数,以了解更加具体的算法逻辑。

总结

CPython 的大部分垃圾收集是通过引用计数完成的,我们无法对其进行干涉调整,通过辅助的分代 GC 来处理循环引用,其可控,即 gc module。关于性能:

  1. 所有对象(包括数字、字符串)都必须实时正确的维护引用计数,这需要额外的内存与 CPU 开销,另外维护容器对象的跟踪字段(也需要额外的内存和 CPU 开销。
  2. WTP 时间其他实现相比相对较少,这得益于引用计数策略(只要没有太多的循环引用),CPython 并没有对此进行特殊优化,如 Golang 的写屏障(Write Barrier)等。
  3. CPython GC 并没有将所有活动的可达对象拷贝到另一整块内存重新编排,所以还是会造成内存碎片。

提示或建议

1. 如果你可以保证不会出现循环引用,则可以通过 gc.disable 完全禁用 GC,在某些情况下,禁用 GC 并手动 gc.collect() 很有用。
2. 为避免产生循环引用,可考虑使用弱引用 weakrefweakref.ref 不会增加引用计数,并且当对象只剩弱引用时不会保持其活性(可以被 GC 正常回收),并在对象被释放后安全返回 None
3. 循环在现实生活中很容易发生。通常,您会在图形,链接列表或结构中遇到它们,在其中需要跟踪对象之间的关系。如果您的程序工作量大且要求低延迟,则需要尽可能避免参考周期。

查找或调试分析循环引用

1. 标准 gc 模块 提供了接口可以帮助调试,例如 gc.set_debug(gc.DEBUG_SAVEALL),则找到的所有的不可达对象将追加到 gc.garbage 列表中,你可以在其中查看。
2. 当你发现了循环引用的对象后,就可以使用上文中提到的 objgraph 来直观的探索它与其它对象间的关系。

参考


同名公众号: