0%

(译)Python 垃圾回收实现 - CPython, PyPy 以及 GaS

译者:写在前面

之前也为一些社区参与过翻译工作,我那时候翻译的大部分都是关于前端的内容,感觉国内前端的社区还是比 Python 的社区活跃不少。现在 Python 借着人工智能的东风开始流行起来,我个人也乐于看到 Python 被更多的开发者乃至企业所接受(好的一面,坏的一面嘛大家也会感受到也不说了)。本人的翻译,力求结合国内计算机技术的实际达到让读者容易理解和接受,不过实力有限,提高也不是一朝一夕的事情,各位有缘能读到我的翻译,请不吝指教,相互交流。

原文地址 ,这也是我拜读《流畅的Python》所获知的,这本书的确是目前市面上进阶的好教材。

摘要

Python 是一个利用不同垃圾回收机制实现的动态语言. 这篇论文对比了 CPython 以及 PyPy, 并且指出了它们因为不同的垃圾回收机制(下文简称为 GC)而导致在行为上的不同. 第一节讨论了常规的 Python, 第二节还有第三节介绍了 CPython 和 PyPy 的垃圾回收器. 然后在第四节会讨论它们在行为上的不同之处. 还有一个选读章节, 就是使用一个第三方库(GaS, 第五节介绍)来在一个高级语言上(这里是 PyPy 的 Python 实现)实现新的 GC.

1 Python 语言介绍

Python 由 Guido van Rossum 在 1989 年创造出来.时至今日, 已经有多个 Python 语言的实现, 而其中 CPython(用 C 语言编写的)是 Python 的参考实现.

最近几年, 也有社区牵头发展了其他不同的 Python 实现: Jython(1997) 是一个用 Java 编写的 Python 编译器, 而且运行在 Java VM 上; 之后又有 IronPython(2006), 是一个为 CLI(.NET) 实现的 Python; 而最年轻的 PyPy(2007) 是由 RPython(一个 Python 的最小子集) 实现的.

PyPy 的代码可以在 CPython 的环境下运行,也可以转译为像 POSIX(C 运行时) 或者 .NET 等低级运行时。

而最近几年 CPython 在发展的过程中也做出了基础修改:Stackless Python(2000) 不再依赖 C 调用栈而使用一个单独的调用栈代替。Psyco(2003) 是一个 X86架构的 Python JIT。Unladen Swallow(2009)使用 LLVM 为 Python 2.6 添加了一个 JIT。

CPython 的实现依赖一个全局解释器锁(GIL)来维持解释器的同步访问 —— Tabba 尝试在硬件层面上使用事务性的内存支持(transactional memory support)为 Python 提供并发,并且也考虑到了垃圾回收的相关限制。

2 CPython 上的垃圾回收

引用计数(Reference counting)

CPython 1.x 仅仅使用引用计数来实现垃圾回收。虽然引用计数易于实现和理解,但是它不足以解决循环引用的问题。于是乎在 1999 年实现了一个循环垃圾回收器,并且附加在了 Python 2 以后的版本上。

现状

CPython 在目前的版本上(论文发表时的版本是 Python 3.2.2 - 译者注)对于垃圾回收使用引用计数以及一个分代垃圾回收器来解决循环引用的问题。归功于引用计数,一个文件可以在一个语句中读写关闭而不用再显示关闭它。下面的语句之所以可以正常运行是因为当文件缓存区刷新(flushed)并关闭的同时,文件对象的引用计数变成 0 :

1
open('test.txt', 'w').write('hello world')

但是这样的语句在其他的 Python 实现如 Jython,IronPython 或这 PyPy 上并不会奏效,因为它们的垃圾回收器会在之后(需要垃圾回收的时间)才会去结束并释放那些文件对象,而在这之后那些文件缓冲区才会被刷新,文件才能被关闭。

为了避免这个问题,可以选择在文件对象进行写操作之后显式关闭文件:

1
2
3
4
# Close the file explicitly with .close()
fp = open('test.txt', w')
fp.write('Hello world')
fp.close()

或者通过一个上下文管理器来隐式关闭文件:

1
2
3
# Use a context manager (PEP343)
with open('test.txt', 'w') as fp:
fp.write('hello world')

在这两种情况下,文件对象依然只会在未来某个不知名的回收时间点上被回收并释放,但在我们代码中它们会有一个确切的点知道需要关闭并刷新(缓冲区)。以上的方法在 CPython 上都可以工作。

CPython 关于引用计数的这个性质在某些场景下可能会有用处,因为开发者可以肯定这个对象会尽可能快的被释放。在一些运行环境的可用内存比较少的情况下,如嵌入式或移动设备上,这可能是一个优点。

3 PyPy 的垃圾回收框架

PyPy 包含了一个“垃圾回收框架”,它允许垃圾回收算法通过高级语言编写同时转译到低级别的语言如C语言上。这种转译对比起手写一个虚拟机来说有若干个有点。PyPy 的 GC 是由 RPython 编写的。

使用高级语言(编写垃圾回收算法)只是 GC 发展的一个方向,因为使用现存的语言会简化工作(由 Wegiel
和 Krintz 使用的另一种方法将会在第 5 节介绍)。

在转译的时候,使用者可以在不同的垃圾回收算法之间选择哪一个最终要编译到目标 Python 解释器,例如 Mark and sweep 标记-清除算法 (经典的 标记-清除算法 算法实现),Semispace copying 半空间复制算法(将垃圾回收器分成两个区域,当已激活区域已经满了的时候将存活的对象复制到另一个区域上),Generational GC 分代垃圾回收算法(作为半空间复制算法 GC 的实现子集,它利用两个代际垃圾回收器来区分年轻代对象和年长代对象),Hybrid GC 混合垃圾回收算法(增加一个额外的世代来处理大对象),Mark & Compact GC 标记-压缩算法(通过就地压缩来节省空间和内存碎片,但是通过多种途径来实现)以及 Minimark GC(将前面的方法结合起来重写的一个自定义分配器)。

除了垃圾回收框架,PyPy 灵活的转译机制允许使用者添加对于 Boehm GC 以及引用计数(不过没有循环引用的检测/采集)内存管理可选项的支持。

4 循环引用,收尾机制以及不可回收对象

循环引用

循环引用是指一个集合的对象中的引用只存在于这个集合之中,简单来说就是集合中的对象相互引用,而没有引用任何集合外的对象(集合中的引用数目大于 0 )。因为没有一个对象可以被外部引用,说明它们它们应该是要被释放掉的。但是引用计数不能处理循环引用。

1
2
3
4
5
6
7
class X:
def __init__(self):
self.other = None

a, b = X(), X() # 引用数都为 1
a.other = b; b.other = a # 引用数都为 2
del a, b # 引用数都为 1(虽然实际的内存对象已经被 delete,但变量标签依然相互引用)

执行完 del 语句之后, ab 都不可达,但是它们的引用数都不为零,所以单单依赖引用计数是无法释放掉它们的。

收尾机制 Finalizer

Python 的 Finalizer(__del__(self) 对象方法)有点类似于 C++ 的析构函数(但是从来不会被显式调用)或者 Java 中的 finalize()。当对象将要被回收/释放的时候(当它的引用数归零或者 GC 要主动回收它),这个 finalizer 就会被调用去完成一些清理/收尾工作。

不可回收对象

引用计数不能检测到循环引用,另一种垃圾回收机制由此而生。Python 2 和 Python 3 的分代垃圾回收器通过随机中断循环来解决这个问题 —— 但这仅仅是在没有 finalizer 需要被执行的情况下工作良好。

处于引用循环中的对象如果有 finalizer,那么在面对 CPython 的垃圾回收器的实现下它永远不会被回收,因为中断循环会改变对象状态。不可回收对象会在 gc 中出现,作为 Python 模块中的 gc.garbage

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

class X:
def __init__(self):
self.other = None

def __del__(self):
print('DEL!', self)

a, b = X(), X()
a.other = b; b.other = a
del a, b
gc.collect(); gc.collect()
print(gc.garbage)

上述代码在 CPython 2.7.2 以及 amd64 机器上会打印以下内容:

1
2
[<main.X instance at 0x7ff5474933b0>,
<main.X instance at 0x7ff5474933f8>]

同样的例子在 PyPy 1.7 下会得到不同的结果:

1
2
3
DEL! <__main__.X object at 0x0000000003490C88>
DEL! <__main__.X object at 0x00000000034E57B8>
[]

译者在 Python 3.5.1 下会得到以下结果:

1
2
3
DEL! <__main__.X object at 0x00000000034A0C88>
DEL! <__main__.X object at 0x00000000034F5978>
[]

我们可以通过明显的 GC 表现来看出不同的 Python 实现:CPython 没有尝试去执行 finalizers ,只是简单地将不可回收对象放到名为 garbage 的全局列表中,让开发者自己手动去处理。

PyPy(使用默认的 GC 机制 “Minimark” )提供了对 finalizer 至多一次 的调用保证(CPython 完全没有提供这样的一个保证机制,连 至少一次 都没有),例如:任何对象的 finalizer 会被调用最多一次,即便这个对象是后来又再生的(可以通过在 finalizer 中暴露 self 作为一个全局引用)。之所以会这样是因为其他对象在第一个对象的 finalizer 被调用时依然拥有着第一个对象的引用(可以把这个对象看做是“自动再生”(automatic reincarnation)的)。

因为一个 finalizer 会被调用最多一次,那么循环引用集合就可以通过简单地把集合中每个对象的 finalizer 都调用一遍来移除,当循环中所有的 finalizer 都被调用过了,就把循环引用集合中的对象内存给释放掉(这时不会执行任何的 finalizer)。

为什么在 PyPy 中可以轻易地解决执行 finalizer 的问题而在 CPython 却不可行呢?原因在于,相比起 没有任何引用计数垃圾回收器 的 PyPy,在一个错杂了引用计数和循环引用侦测的环境下,执行 finalizer 要来的更加严格和困难。

5 作为服务的垃圾回收

如我们所见,PyPy 里面的高级别 GC 描述可以提供到给开发者和使用者不少的好处。但是,这不是唯一节省时间的方法来实现试验性的垃圾回收器。

Wegiel 和 Krintz 研究了他们的 GC-as-a-Service (GaS, 垃圾回收即服务) 垃圾回收库。这个想法是在这个库中实现一个最先进的,可并发协作的垃圾回收器,通过一个预定义的接口让别的运行时如 Java VM,Python VM 以及 Ruby VM 都可以被钩入(hook into)进来。这个垃圾回收器是不可移动的,这意味着它仅支持那些假定对象不能在内存中被移动的虚拟机(VMs)。

虽然 PyPy 提出了一种实现虚拟机(VMs)的有效建议:将高级语言转译为低级语言,并用高级语言去描述一个组件来作为垃圾回收器,Gas 提出了一个迥然不同的思路并尝试把 GC 的逻辑放到一个模块库中。这也有利于代码重用,但它目前仅允许利用一个垃圾回收器(对比 PyPy 的多种垃圾回收途径)。而 GaS 不是使用高级语言编写的,对比起转译的方法,它更加难以检查和实现。

GaS 目前是作为四相(four-phase) GC 来实现,它们四者都是并行执行的:标记清除(flag clearing)、根转存(root dump)、对象标记(object marking)以及对象扫描(object-sweeping)。

因为 GaS 集成的 Python 实现是 CPython,所以 GaS 不得不考虑引用计数的影响。并且通过有条件地使用 incref(增加引用计数) 和 decref(减少引用计数) 操作,避免了在 GaS 的堆上分配对象的引用计数。

6 结论

PyPy 的研究项目包含了一个框架,用来尝试实现比 CPython 的 GC 实现更加复杂的垃圾回收算法。而 GaS 作为一个可插拔的 GC 库,是 PyPy 高级别 GC 框架的一个替代方案。文中有一个针对 PyPy 给出的不同垃圾回收算法的简短概述,紧接着一个例子来分析它们在行为上的差异。CPython 和 PyPy 在何时以及多长时间去调用对象的 finalizer 有着不同的实现语义以及保障机制。

结论 1

由于历史和实际的原因,各种 Python 实现在面对 GC 以及 finalizer 时有着不同的行为和语义。开发人员在编写可移植代码时必须意识到这个问题所在,不能依赖某一个实现的隐式行为。

结论 2

为了用现有的语言去简化新垃圾回收器的开发,垃圾回收器可以用高级语言去编写,然后转译到目标运行时上(PyPy 的方法),或者写一个为虚拟机提供特定的接口外部库(GaS 的实现)。