06 内存分配与垃圾回收:Python 在垃圾回收中如何解决循环引用问题?
Diego38 33 1

引言

能否写出高质量的代码是衡量一个软件工程师( Software Engineer )是否优秀的重要依据,而高质量代码的一个重要特质便是能够合理有效的利用内存。为了提高 Python 程序的内存利用率,了解和掌握 Python 内存管理的基本原理是必不可少的。本小节,我们便从内存入手,对 Python 中的内存分配和垃圾回收机制进行着重讲解。

内存与内存管理简介

内存是什么?

从计算机存储器的作用来讲,存储器可以分为主存储器,辅助存储器和缓冲存储器。

其中主存储器,也称主存、内存或可执行存储器,是与 CPU 直接进行信息交换的存储器,它的读写速度相对较快,容量相对较小,通常用来保存进程运行时的程序和相应数据以供 CPU 使用;而辅存不能与 CPU 直接进行信息交换,它的容量较大,读写速度相对较慢。缓冲存储器常用于两个速度不同的部件之间,比如 CPU 和主存之间设置的高速缓冲存储 Cache(也可将内存的概念扩展为主存和高速缓冲存储器的合集)。

image

操作系统的内存管理

由于内存的容量有限,很难承载系统以及用户进程所需的全部信息,所以操作系统需要对内存空间进行存储管理。在操作系统层面上内存管理的实现相当复杂,其大致功能包括内存空间的划分与动态分配、回收、扩充,以及存储保护,地址转换等等。

进程内的内存管理

操作系统对各个进程的内存进行管理,同时我们也需要管理我们编写的程序所对应进程内的内存。从可用内存中申请内存并且具有足够内存来进行相关操作,以及在适当的时间释放内存,这些都是我们的程序和系统能够正常运行的前提。

在 C / C++ 中,我们需要手动的进行内存管理,比如 C 语言中通过 malloc 和 free 函数来申请给定字节数的内存以及释放对应的内存。但在 Python 中,我们无须手动进行内存的申请和释放,Python 在内部帮我们完成了大量涉及到内存管理的操作,包括内存分配及垃圾回收。

image

内存分配

内存池机制

什么是 PyMalloc ?

在 Python 中,有很多常用的数据结构,包括列表、字典等。比如在列表中,我们不仅可以保存其他不同类型的对象,而且可以非常方便的使用 append、extend 等方法对其进行动态的扩充。针对这些常用对象的一系列操作,会在 Python 中造成内存的频繁分配和释放,同时像 int、list 等 Python 对象的分配和释放通常涉及到的数据量相对较小,因此 Python 在内部引入了内存池机制,实现了小块内存的管理器(称为 PyMalloc )用于提高处理小块内存的效率,这样避免了在底层频繁的 malloc 和 free 操作对效率带来的影响。

分配策略

在内存分配中,Python 以 512 bytes 为界限对大内存和小内存进行划分,不超过 512 bytes 的内存申请,会通过 PyMalloc 管理器进行处理,超过 512 bytes 的内存申请,则会通过 C 中的 malloc 来进行处理。在管理器的内部,主要包括 block、pool、arena 层级, 其中 block是 Python 内存管理中的最小单元,一个 pool 中包含多个 block,多个 pool 构成一个 arena。 同时,由于内存池机制,Python 并不会将释放的内存立即归还操作系统。

缓冲池机制

在内存池机制的基础之上,Python 为了提高常用对象的创建和释放效率,又进一步对整数、字符串等对象建立了对象缓冲池。比如对于 [-5, 256] 内的小整数,Python 已经在内部创建好了对应的小整数对象的缓冲池。

垃圾回收

垃圾回收是一种自动回收内存的技术,会将不再被使用的内存空间进行释放。在 CPython 中,垃圾回收主要是通过引用计数、标记清除及分代回收来完成的。使用引用计数来检测并清除不可访问的对象( inaccessible objects ),并结合标记清除及分代回收来收集、定期检测及清除具有引用循环的对象。

在 CPython 之外的一些其他实现中,它们的垃圾回收机制和 CPython 通常会有所不同,比如 PyPy 或 Jython。一些依赖 CPython 中引用计数的代码在这些实现下可能会出现问题,比如在 CPython 中使用 open() 打开相关文件后,可以不用显式的关闭它:

for line in open(file):
    do_something_with_line()

但在其他实现中如果不显式的关闭打开的文件,则有可能会出现文件描述符耗尽的情况。如果想要你的代码移植性更好,可以使用下面 with 语句的方法或者显式的关闭文件,这样代码不但不依赖于任何实现,从而在可移植性和通用性上更好。

with open(file) as f:
    for line in f:
        do_something_with_line()

垃圾回收机制虽然隐藏在 Python 内部,但是它非常重要,相比于 C 程序员手动进行内存的申请和释放(可能会造成内存泄露等相关问题),Python 的垃圾回收机制帮我们在背后完成了大量工作。通过下面几个方面,我们逐步了解 Python 中的垃圾回收机制。

Python 中的引用关系:名字、对象、引用

想要理解 Python 的垃圾回收机制,需要先理解 Python 中名字和对象之间的引用关系。

我们先来看看在 C 语言中有关变量声明等过程的一段代码:

 int a = 1;
 a = 2;

第一行表示声明及初始化变量,即为变量创建和标记存储空间,并指定了初始值;第二行表示为变量重新赋值。整个过程可以使用下图来表明,可以看到的是,在我们给变量重新赋值后,相同地址上的值被修改为 2。变量名 a 可称为标识符(在这里我们可以把变量理解为一块内存,把变量名理解为内存别名),声明过程把标识符和内存中某个特定的位置关联了起来(同时也确定了该位置存储的数据类型)。

image

如果同样功能的代码,我们在 Python 下应该是怎样的过程?

a = 1
a = 2

在下图中便可以说明,这个过程显然和上图有很大区别。详细的说,在 Python 中,我们可把变量名称为名字(name),把对象理解为分配的一块内存,名字和对象之间的关联关系称为引用(有 C 语言基础的同学一定会发现,这种引用关系和 C 语言中的指针非常类似)。所以我们重新赋值,修改的是引用关系,名字 a 重新关联到另外一个对象 2 上,而不是修改原来的对象 1 ,正如下图所示。

image

注意,Python 中的名字和上面提到 C 语言中的标识符,我们可以把它们都理解为变量名,但在两种语言中,对变量名的处理方式是不同的(这也是这里我们选择把它们的名称区分开的原因,都称为变量名或许会造成一种理解上的困惑)。C 语言中的标识符相当于内存的别称,在执行过程中会被替代;而在 Python 中,名字会参与到运行过程中,并且名字和对象的映射关系会存放在命名空间中。

>>> a = 1
>>> globals()  # 以字典形式返回全局命名空间
{..., 'a': 1, ...}

引用计数(Reference Counting)

基本概念

在理解了引用的概念之后,引用计数又是什么呢?引用计数是一种内存管理技术,通过对对象的引用计数进行跟踪实现自动的内存管理,Python 的垃圾回收便是以引用计数为主的。具体的说,对象的引用计数随着程序的运行进行变动,当对象的引用计数变为零时,对应的内存便会被释放。可以通过 sys.getrefcount 来查看某个对象的引用计数。

>>> import sys
>>> a = "hello world"
>>> sys.getrefcount(a)  # 输出结果会比我们预期多1,因为a在sys.getrefcount()中作为参数也被引用了一次
2

引用计数的实现

在 Python 的对象实现中,每个对象中都持有一个统计引用次数的计数器,当该计数器变为 0 时,对应对象的内存空间就会被回收。感兴趣的同学可以参考源码 object.h 中的 PyObject ,它是 Python 对象系统中最基础的部分,可以看到它的一个成员便是引用计数变量 ob_refcnt。

typedef struct _object {
    // 省略部分代码
    Py_ssize_t ob_refcnt;
    struct _typeobject *ob_type;
} PyObject;

引用计数的问题

首先,引用计数在原理和实现上相对简单,并且延迟低、实时性强(计数变为 0 时便会立即释放内存),并且对于内存的回收分布在程序的运行时,不会造成瞬时的卡顿。但同时引用计数也导致了较多的空间占用(前文提到的每个对象都有持有一个引用计数变量 ob_refcnt),频繁的引用计数增减。与此同时,引用计数还存在两个相对严重的问题:

  • 引用计数无法处理循环引用。
  • 引用计数是非线程安全的(这个问题,我们会在 GIL 相关的小节中详细说明)。

    循环引用

    什么是循环引用呢?我们先来看一段代码:
    >>> class Container:
    ...     def point(self, value):
    ...         self.point = value
    ...         
    >>> container_one = Container()
    >>> container_two = Container()
    >>> container_one.point(container_two)
    >>> container_two.point(container_one)
    这里我们可以使用 objgraph 来可视化我们上述创建的对象(objgraph是一个用于定位内存泄露的工具),大家是否还得在虚拟环境下安装第三方库的方法:
    # 在终端中进入虚拟环境
    workon python-core-tech
    # 安装第三方库
    pip install objgraph
    # 下面的库会帮助我们生成可视化图片
    sudo apt install graphvi
>>> import objgraph
>>> objgraph.show_backrefs(container_one, max_depth=4, filename="container.png")
Graph written to /tmp/objgraph-o748tdfx.dot (31 nodes)
Image generated as container.png

这里我们截取生成图片的一部分,可能有些同学看到下面的图会有一些困惑,不要着急,我们目前只需要定位我们代码中的关键字即可,比如 container_one、container_two、point。

image

我们把上面的关系简化一下便可以得到下图,指向对象的箭头数量可以理解为对象的引用计数, container_one、container_two 的引用计数都为 2。如下图所示,container_one、container_two 之间便构成了循环引用。循环引用是一个对象直接或者间接引用自身,从而在引用关系上构成环状结构(类似 container_one.point(container_one) 就是直接引用自身的一个例子)。另外,在 Python 中我们将能够持有其他对象引用的对象称为容器( container ),只有容器对象才能造成循环引用。

image

那么循环引用会导致什么问题?我们清除掉命名空间内container_one、container_two的引用。

del container_one
del container_two

如下图,在上面的引用被解除后,我们预期这两个对象的内存应被回收,但这两个对象由于循环引用而导致其引用计数仍然为1,根据引用计数并不能对其内存进行释放。当这种循环引用的对象达到一定数量后,便会造成比较严重的内存泄露问题。

image

另外一种常见的循环引用情况是一个序列对象包含自身的引用,比如下面这个例子。可以看到,在这种情况下,Python 会自动将循环引用部分打印为 [...],从而避免严重的无限循环。

>>> l = [1, 2, 3]
>>> l.append(l)
>>> l
[1, 2, 3, [...]]

标记清除(Mark and Sweep)和分代回收(Generational)

为了解决引用计数在垃圾回收中无法处理循环引用的问题,Python 引入了标记清除和分代回收来检测和打破循环引用 。相对准确的说,标记清除是追踪回收( Tracing garbage collection )中的一种基础算法,其涉及到两个主要过程,即标记过程和清除过程,在标记过程中将所有可达(reachable)对象进行标记,在清除过程中将所有未标记的对象进行清除。分代回收则是基于标记清除基础上的,一种空间换时间的实现策略。

分代回收

分代回收将 Python 对象划分成 3 代,包括 0、1、2 代。对于新创建的对象,会被放入 0 代。若一个对象在经过一次垃圾回收后没有被清除,则它会被放入下一代中。对于每一代对象来说,都具有触发垃圾回收的相关阈值(收集频率)。关于这个过程的细节,官方文档中给出了比较明确的描述:

垃圾回收器把所有对象分类为三代,取决于对象幸存于多少次垃圾回收。新创建的对象会被放在最年轻代(第 0 代)。如果一个对象幸存于一次垃圾回收,则该对象会被放入下一代。第 2 代是最老的一代,因此这一代的对象幸存于垃圾回收后,仍会留在第 2 代。 为了判定何时需要进行垃圾回收,垃圾回收器会跟踪上一次回收后,分配和释放的对象的数目。当分配对象的数量减去释放对象的数量大于阈值 threshold0 时,回收器开始进行垃圾回收。起初只有第 0 代会被检查。当上一次第 1 代被检查后,第 0 代被检查的次数多于阈值 threshold1 时,第 1 代也会被检查。相似的, threshold2 设置了触发第 2 代被垃圾回收的第 1 代被垃圾回收的次数。

分代回收的主要目的是降低回收中需要处理的对象数量,提高垃圾回收效率。我们可以使用 gc 模块来执行或优化垃圾回收的相关过程,并获取更多的调试信息,比如通过 gc.get_threshold() 来获取当前的回收阈值;通过 gc.disable() 关闭垃圾回收,这通常在程序中确定不存在循环引用时使用。

另外,一个常见的问题是 CPython 在退出时一定会释放所有内存吗?答案是否定的,当 Python 解释器退出时,会进行内存清理,试图释放每个对象的内存,但不一定会释放所有内存,比如全局命名空间中引用的某些对象、循环引用下 C 扩展库中分配的某些内存都有可能不被释放(一部分相关细节可以通过 gc.garbage 了解)。

总结

在本小节中,我们从内存出发,对 Python 中的内存分配机制、垃圾回收机制这两个重要的话题进行了一定的讲解。在这两个话题中,涵盖的内容和细节非常多,它们也可能会随着 Python 版本进行变动。同时在小节中一些内容分支的描述上,在讲解内容的同时也想起到抛砖引玉的作用,感兴趣的读者可以阅读更多的资料,并结合源码去探究深层次的实现方式。

预览图
评论区

索引目录