Python 进阶:浅析「垃圾回收机制」

花下猫语: 近半个月里,我连续写了两篇关于 Python 中内存的话题,所关注的点都比较微小,猎奇性质比实用性质大。作为对照,今天要分享一篇长文,是跟内存相关的垃圾回收话题,一起学习进步吧!

作者:二两  || 来源:hackpython

宣传海报 | 《罗小黑战记》

Python 垃圾回收机制是很多 Python 岗位面试官喜欢提的一点 ,虽然 Python 具有垃圾自动回收的机制,但在一些大型项目中有些资源是不能等到它自动回收的,而需要手动将使用完的资源回收释放,从而让程序尽可能的耗尽服务器的所有资源,这在游戏开发中很重要,服务器是需要成本的 。

Python 中垃圾回收机制 (Garbage Collection, GC) 主要使用「引用计数」进行垃圾回收,通过「标记 - 清理」解决「容器对象」产生循环引用的问题,在通过「分代回收」以空间换时间的方式来提高垃圾回收的效率。

下面分别从「引用计数」、「标记 - 清理」以及「分代回收」来讨论一下 Python 中的 GC。

引用计数

从 CPython 源码中,Python 对象的核心是 PyObject 这个结构体:kissing:,该结构体内存通过 ob_refcnt 实现变量的引用计数,PyObject 结构体如下:

程序在运行的过程中会实时的更新 ob_refcnt 的值,来反映引用当前对象的名称数量。当某对象的引用计数值为 0, 那么它的内存就会被立即释放掉,即被垃圾回收。

以下情况是导致引用计数加一的情况:

:grinning:1. 对象被创建,例如 a=2333:grinning:2. 对象被引用,b=a:grinning:3. 对象被作为参数,传入到一个函数中:grinning:4. 对象作为一个元素,存储在容器中

下面的情况则会导致引用计数减一:

1. 对象别名被显示销毁 del 2. 对象别名被赋予新的对象 3. 一个对象离开他的作用域 4. 对象所在的容器被销毁或者是从容器中删除对象

可以通过 sys 包中的 getrefcount () 来获取一个名称所引用的对象当前的引用计数 (注意,这里 getrefcount () 本身会使得引用计数加一)

「引用计数」这种方式很容易从逻辑层面去理解,简单而言就是有人用旧留着,没人用就回收,但这种方式是比较耗费资源的,毕竟计数也需要占用内存,而且该方法无法解决「容器对象」循环引用的问题,如下:

循环引用导致变量计数永不为 0,造成引用计数无法将其删除。

标记 - 清除

Python 中使用标记 - 清除的方式来解决循环引用导致的问题。

只有容器对象才会产生循环引用的情况,比如列表、字典、用户自定义类的对象、元组等。而像数字,字符串这类简单类型不会出现循环引用。

「标记 - 清除」作为一种优化策略,对于只包含简单类型的元组也不在标记清除算法的考虑之列,简单来看,「标记 - 清除」算法在进行垃圾回收时分成了两步,分别是:

A)标记阶段,遍历所有的对象,如果是可达的(reachable),也就是还有对象引用它,那么就标记该对象为可达; B)清除阶段,再次遍历对象,如果发现某个对象没有标记为可达,则就将其回收。

下面看图来理解 标记 - 清除 ,图片出自 聊聊 Python 内存管理

在标记清除算法中,为了追踪容器对象,需要每个容器对象维护两个额外的指针,用来将容器对象组成一个双端链表,指针分别指向前后两个容器对象,方便插入和删除操作:sunglasses:。python 解释器 (Cpython) 维护了两个这样的双端链表,一个链表存放着需要被扫描的容器对象,称为 Object to Scan,另一个链表存放着临时不可达对象,称为 Unreachable。

上图中 link1,link2,link3 组成一个引用环,此外 link1 还被变量 A 引用,看图中 link1 被几个箭头指着就知道了,其中 ref count 记录当前对象的引用计数,而 gc ref 在一开始,gc ref 只是 ref count 的副本,所以 gc ref 的初始值等于 ref count。

gc 启动的时候,会逐个遍历”Object to Scan” 链表中的容器对象,并且将当前对象所引用的所有对象的 gc ref 减一:neutral_face:。这一步操作就相当于解除了循环引用对引用计数的影响。如 link4 是自己引用了自己造成了循环引用,此时 link4 的 gc ref 为 0.

接着,gc 会再次扫描所有的容器对象,如果对象的 gc ref 值为 0,且引用该对象的对象其 gc ref 也为 0 ,那么这个对象就被标记为 GC TENTATIVELY UNREACHABLE,并且被移至”Unreachable” 链表中:neutral_face:。下图中的 link3 和 link4 就是这样一种情况。

如果对象的 gc ref 不为 0,那么这个对象就会被标记为 GC REACHABLE:neutral_face:。同时当 gc 发现有一个节点是可达的,那么他会递归式的将从该节点出发可以到达的所有节点标记为 GC REACHABLE, 这就是下图中 link2 和 link3 所碰到的情形:hushed:。除了将所有可达节点标记为 GC REACHABLE 之外,如果该节点当前在”Unreachable” 链表中的话,还需要将其移回到”Object to Scan” 链表中,下图就是 link3 移回之后的情形。

第二次遍历的所有对象都遍历完成之后,存在于”Unreachable” 链表中的对象就是真正需要被释放的对象。如上图所示,此时 link4 存在于 Unreachable 链表中,gc 随即释放之。

上面描述的垃圾回收的阶段,会暂停整个应用程序,等待标记清除结束后才会恢复应用程序的运行 。

分代回收

引用计数 + 标记 - 清除 的方式实现了 Python 垃圾回收,但整个过程比较慢,而且在 标记 - 清除 过程中还需要暂停整个程序,为了减少应用程序暂停使用,Python 利用分代回收 (Generational Collection) 以空间换时间的方式来提高垃圾回收效率:hushed:。

分代回收是基于这样的一个统计事实,对于程序,存在一定比例的内存块的生存周期比较短;而剩下的内存块,生存周期会比较长,甚至会从程序开始一直持续到程序结束。生存期较短对象的比例通常在 80%~90% 之间,这种思想简单点说就是:对象存在时间越长,越可能不是垃圾,应该越少去收集 。这样在执行标记 - 清除算法时可以有效减小遍历的对象数,从而提高垃圾回收的速度。

python gc 给对象定义了三种世代 (0,1,2), 每一个新生对象在 generation zero 中,如果它在一轮 gc 扫描中活了下来,那么它将被移至 generation one, 在那里他将较少的被扫描,如果它又活过了一轮 gc, 它又将被移至 generation two,在那里它被扫描的次数将会更少 。

当某一世代被分配的对象与被释放的对象之差达到某一阈值的时候,就会触发 gc 对某一世代的扫描。值得注意的是当某一世代的扫描被触发的时候,比该世代年轻的世代也会被扫描:hushed:。也就是说如果世代 2 的 gc 扫描被触发了,那么世代 0, 世代 1 也将被扫描,如果世代 1 的 gc 扫描被触发,世代 0 也会被扫描。

该阈值可以通过下面两个函数查看和调整:

gc 会记录自从上次收集以来新分配的对象数量与释放的对象数量,当两者之差超过 threshold0 的值时,gc 的扫描就会启动,初始的时候只有世代 0 被检查。如果自从世代 1 最近一次被检查以来,世代 0 被检查超过 threshold1 次,那么对世代 1 的检查将被触发。相同的,如果自从世代 2 最近一次被检查以来,世代 1 被检查超过 threshold2 次,那么对世代 2 的检查将被触发。

结尾

本节中简单的讨论了 Python 中的垃圾回收机制,那是否有某些手段可以比较直观的看出当前项目中 Python GC 的使用情况,从而可以直观的判断项目对内存的使用是否合理呢?这些内容会尝试在浅析「垃圾回收机制」下篇中讨论:smirk:

:wave::wave:

花下猫语: 以上内容是“上篇”,为便于大家收藏阅读,我把下篇也分享在一起了:

Python 进阶: 浅析「垃圾回收机制」(下篇)

Python 垃圾回收机制本质就是对内存的操作机制,当程序需要长时间运行时,其内存的变化就变得关键,如果没有及时释放内存,即 Python 自动垃圾回收机制因为我们某些代码逻辑上的错误而导致某些内存一直不能被回收,从而造成程序的内存泄露。

Python 为 GC 提供了扩展模块 gc,利用 gc 模块提供的接口,可以查看到垃圾收集器的状态,垃圾收集器收集的对象、被收集对象的详细信息等。

在上篇中,我们简要的讨论了「垃圾回收机制」的原理,本篇则来讨论一下操作层面的内容。

gc 模块概况

虽说 Python 垃圾回收是自动的,不需要人为的干预,但人为干预的情况并不少见,如在游戏公司,为了提高 Python 运行的效率,Python GC 被开发人员手动关闭,再在某些情况下打开,但默认情况下,Python GC 通常只会在下面 3 种情况触发:

:hushed:1. 人为手动调用了 gc.coolect () :hushed:2.GC 的计数器到达阈值时 :hushed:3.Python 程序退出时

我们可以利用 gc 模块来操作 Python 的 GC,在具体操作前,先理解其提供方法的大致功能。

:kissing:gc.isenabled () 判断 Python 程序在当前的状态的下是否已经打开自动垃圾回收机制,如果已经打开,该方法返回 True。

:kissing:gc.disable () 该方法用于关闭自动垃圾收集器,关闭自动垃圾收集器后,程序产生的垃圾对象 (不可访问的对象) 不会被自动回收,会持续的占用内存。

:kissing:gc.collect ([generation]) 显式进行垃圾回收,可以输入参数,0 代表只检查第一代的对象,1 代表检查一,二代的对象,2 代表检查一,二,三代的对象,如果不传参数,则启动完全的垃圾回收,也就是等于传 2。该方法会返回不可达对象的个数。

:kissing:gc.set_debug (flags) 将垃圾收集器设置为调试状态,在该状态下,垃圾收集器会打印出收集到的所有对象信息并将不可访问的垃圾对象保存到 gc.garbage 列表中。

:kissing:gc.set_threshold (threshold0 [, threshold1 [, threshold2]) 设置自动执行垃圾回收的频率。

:kissing:gc.garbage 列表,列表内部存放着垃圾收回器找到的不可达并且无法被释放的对象,通常这些对象会一直存在到程序结束,如果程序要长时间运行,如果 gc.garbage 列表中的对象一直在增多,容易造成内存泄露。

:kissing:gc.is_tracked (obj) 该方法用于判断某个变量是否被垃圾回收器监控,如果是,则返回 True,否则返回 False,通常只有非原子类(如容器、用户自定义对象)会被监控,这是为了避免循环引用的情况出现。

:kissing:gc.get_count () 获取当前自动执行垃圾回收的计数器,返回一个长度为 3 的列表

常用于 set_debug () 的 flags:

gc.DEBUG_STATS 表示打印垃圾回收器回收完后的统计信息,当回收频率较高时,这些信息比较有利。

gc.DEBUG_COLLECTABLE 发现可回收对象时打印信息

gc.DEBUG_UNCOLLECTABLE 打印找到的不可回收对象的信息(指不能被回收器回收的不可达对象)。这些对象会被添加到 gc.garbage 列表中,即不可达又不能被释放的对象

gc.DEBUG_SAVEALL 设置后,所有垃圾回收器找到的不可达对象会被添加进 garbage 而不是直接被释放。这在调试一个内存泄漏的程序时会很有用。

gc.DEBUG LEAK 调试内存泄漏的程序时,使回收器打印信息的调试标识位。(等价于 DEBUG COLLECTABLE | DEBUG UNCOLLECTABLE | DEBUG SAVEALL )

更多功能,可以参考官方文档:gc --- 垃圾回收器接口 — Python 3.8.0b2 文档

内存泄露

我们已经知道 Python 利用「标记 - 清除」算法来解决循环引用的情况,但在 Python2.7 中要依赖于对象的 __del__ 方法,如果该方法被用户自定义了,则「标记 - 清除」就无法打破循环引用,从而出现不可达且不可释放的垃圾对象,一个具体的例子:

如果在 Python2.7 下运行上述代码,输出如下内容:

可以看出有 2 个变量是不可释放的,如果这类对象随着程序运行的时长而增加,就容易造成程序的内存泄露,从打印也可以知道,这两个对象是 A 类实例,而且观察 A 类中的 __del__ 方法,其中的内容并

但这类问题在 Python3.5 及以上版本的 Python 中没有出现 (没有测试 Python3.5 以下版本的 Python),在 Python3.7 下运行上面程序的结果如下:

从结果可以看出,Python3.7 中已经将这一的问题修复了。

Python GC 分代回收使用

Python 为了保证 GC 的速度,使用了分代回收的策略,即不对变量立刻进行回收,在上篇中提及了其中的原理,在一轮 gc 扫描中存活下来的变量存到对应「代」的列表中,其分为第 0 代、第 1 代与第 2 代,存活到最后,gc 扫描的频率就越低,直到触发对应的阈值。

gc 模快有一个自动垃圾回收的阀值,即通过 gc.get_threshold 函数获取到的长度为 3 的元组,其默认值为 (700,10,10)。每一次计数器的增加,gc 模块就会检查增加后的计数是否达到阀值的数目,如果是,就会执行对应的代数的垃圾检查,然后重置计数器 例如,假设阀值是 (700,10,10):

:flushed:1. 当计数器从 (699,8,0) 增加到 (700,8,0),gc 模块就会执行 gc.collect (0), 即检查 0 代对象的垃圾,并重置计数器为 (0,9,0)

:flushed:2. 当计数器从 (699,9,0) 增加到 (700,9,0),gc 模块就会执行 gc.collect (1), 即检查 1、2 代对象的垃圾,并重置计数器为 (0,0,1)

:flushed:3. 当计数器从 (699,9,9) 增加到 (700,9,9),gc 模块就会执行 gc.collect (2), 即检查 0、1、2 代对象的垃圾,并重置计数器为 (0,0,0)

可以通过 set_threshold () 函数来设置不同代之间的阈值,从而实现控制 gc 扫描频率的目的,简单代码如下:

上述代码中,使用 set debug () 设置 gc 模块为 debug 模式,方便查看信息,再利用 set threshold () 方法设置分代回收的阀值,从而控制 Python GC 的频率

简单使用一下,首先将分代回收阈值设置为 (100,1,1)

接着将其设置小一些

从打印可以看出,将分代回收阈值设置为 (2,1,1) 后,Python GC 执行的频率明显更频繁,但这会在一定程度上影响程序的效率。

禁用 GC 调高速度

从前面的介绍可知,Python的引用计数会在每个内存对象中都存在一个计数变量,当有大量的对象新建或删除时,就会涉及到该变量的大量修改,从而影响程序的性能,为了避免这种情况,在程序进行大量对象新建或删除前,可以先将GC禁用,等这些操作结束后,再开启GC,例子如下:

import gc
# 关闭GC
gc.disable()
# do something
# 开启GC
gc.enable()
# 手动执行GC
gc.collect()

此时就会在批量操作后,对这些变量进行批量的回收。

结尾

本节主要讨论了 Python 中的 gc 模块并简单的使用了该模块,希望读者们有所收获。

告诉朋友们,我在看

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章