专栏首页Python无止境Python 进阶:浅析「垃圾回收机制」

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

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

简介

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

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

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

引用计数

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

typedef struct_object {
    int ob_refcnt;
    struct_typeobject *ob_type;
} PyObject;

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

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

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

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

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

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

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

a=[1,2] # 计数为 1
b=[2,3] # 计数为 1
a.append(b) # 计数为 2
b.append(a) # 计数为 2
DEL a # 计数为 1
DEL b # 计数为 1

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

标记 - 清除

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

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

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

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

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

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

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

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

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

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

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

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

分代回收

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

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

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

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

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

import gc
gc.get_threshold() # (threshold0, threshold1, threshold2).
gc.set_threshold(threshold0[, threshold1[, threshold2]])

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

结尾

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

??


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

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

简介

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

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

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

gc 模块概况

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

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

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

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

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

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

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

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

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

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

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

常用于 set_debug () 的 flags:

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

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

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

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

?gc.DEBUGLEAK 调试内存泄漏的程序时,使回收器打印信息的调试标识位。(等价于 DEBUGCOLLECTABLE | DEBUGUNCOLLECTABLE | DEBUGSAVEALL )

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

内存泄露

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

#coding:utf-8
import time
import gc

class A():
    def __del__(self):
        print('A del')


def test_leak():
    c1 = A()
    c2 = A()
    # 循环引用
    c1.t = c2
    c2.t = c1
    # 删去
    del c1
    del c2

if __name__ == '__main__':
    test_leak()
    # collect() 显示触发垃圾回收,并获得不可达对象个数
    print(gc.collect())
    # 不可释放的个数
    print(len(gc.garbage))
    print(gc.garbage)

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

4
2
[<__main__.A instance at 0x103faea70>, <__main__.A instance at 0x103faeab8>]

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

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

A del
A del
4
0
[]

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

Python GC 分代回收使用

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

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

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

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

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

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

import gc
import sys

threshold = int(sys.argv[1])

class A(object):
    def __init__(self, name):
        self.name = name
        print('Create', self.name)

# 打印垃圾回收器回收完后的统计信息
gc.set_debug(gc.DEBUG_STATS)
# 设置分代回收阀值
gc.set_threshold(threshold, 1, 1)
print('Thresholds:', gc.get_threshold())

print('Clear the collector by forcing a run')
gc.collect()
print()

print('Creating objects')
alist = []
for i in range(10):
    alist.append(A('A'+str(i)))

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

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

python -u test4.py 100
Thresholds: (100, 1, 1)
Clear the collector by forcing a run
gc: collecting generation 2...
gc: objects in each generation: 574 3814 0
gc: objects in permanent generation: 0
gc: done, 0.0004s elapsed

Creating objects
Create A0
Create A1
Create A2
Create A3
Create A4
Create A5
Create A6
Create A7
Create A8
Create A9
gc: collecting generation 2...
gc: objects in each generation: 11 0 4260
gc: objects in permanent generation: 0
gc: done, 0.0004s elapsed
gc: collecting generation 2...
gc: objects in each generation: 66 0 4177
gc: objects in permanent generation: 0
gc: done, 589 unreachable, 0 uncollectable, 0.0005s elapsed
gc: collecting generation 2...
gc: objects in each generation: 0 0 3029
gc: objects in permanent generation: 0
gc: done, 151 unreachable, 0 uncollectable, 0.0002s elapsed

接着将其设置小一些

python -u test4.py 2
Thresholds: (2, 1, 1)
Clear the collector by forcing a run
gc: collecting generation 2...
gc: objects in each generation: 574 3814 0
gc: objects in permanent generation: 0
gc: done, 0.0004s elapsed
gc: collecting generation 0...
gc: objects in each generation: 1 0 4261
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed

Creating objects
gc: collecting generation 0...
gc: objects in each generation: 3 0 4261
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
Create A0
gc: collecting generation 1...
gc: objects in each generation: 2 2 4261
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
Create A1
Create A2
gc: collecting generation 0...
gc: objects in each generation: 2 0 4264
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
Create A3
gc: collecting generation 0...
gc: objects in each generation: 3 1 4264
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
Create A4
Create A5
gc: collecting generation 1...
gc: objects in each generation: 2 3 4264
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
Create A6
gc: collecting generation 0...
gc: objects in each generation: 3 0 4268
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
Create A7
Create A8
gc: collecting generation 0...
gc: objects in each generation: 2 2 4268
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
Create A9
gc: collecting generation 2...
gc: objects in each generation: 1 3 4267
gc: objects in permanent generation: 0
gc: done, 0.0003s elapsed
gc: collecting generation 0...
gc: objects in each generation: 5 0 4216
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
gc: collecting generation 0...
gc: objects in each generation: 6 5 4216
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
gc: collecting generation 1...
gc: objects in each generation: 6 11 4216
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
gc: collecting generation 0...
gc: objects in each generation: 6 0 4233
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
gc: collecting generation 0...
gc: objects in each generation: 8 6 4232
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
gc: collecting generation 1...
gc: objects in each generation: 6 14 4232
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
gc: collecting generation 0...
gc: objects in each generation: 10 0 4245
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
gc: collecting generation 0...
gc: objects in each generation: 4 10 4244
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
gc: collecting generation 1...
gc: objects in each generation: 3 14 4244
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
gc: collecting generation 0...
gc: objects in each generation: 3 0 4261
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
gc: collecting generation 0...
gc: objects in each generation: 3 3 4261
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
gc: collecting generation 1...
gc: objects in each generation: 4 6 4260
gc: objects in permanent generation: 0
gc: done, 0.0000s elapsed
gc: collecting generation 2...
gc: objects in each generation: 2 0 4241
gc: objects in permanent generation: 0
gc: done, 589 unreachable, 0 uncollectable, 0.0004s elapsed
gc: collecting generation 2...
gc: objects in each generation: 0 0 3029
gc: objects in permanent generation: 0
gc: done, 151 unreachable, 0 uncollectable, 0.0002s elapsed

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

禁用 GC 调高速度

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

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

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

结尾

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

本文分享自微信公众号 - Python猫(python_cat)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-09-17

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 面向对象编程是否走向了消亡?

    【CSDN 编者按】作为一种程序设计思想,OOP 在最初诞生之际就收到了广大开发者的喜爱。但是在技术革新日益实践过程中,不少人发现面向对象的设计会使代码复杂化,...

    Python猫
  • 辨析编程语言的四种类型:动静类型与强弱类型

    导读:当描述一门编程语言的时候,我们一般需要区分它是动态类型还是静态类型,区分它是强类型还是弱类型。然而,很多人会将这几种类型搞错。本文的目的就是来辨析清楚这四...

    Python猫
  • Python中的“特权种族”是什么?

    前几天,某个学习群里有小伙伴问了一个关于id()的问题。事后,猫猫想起Python中一些常用对象的内存地址是共用的,但是具体是哪些却忘了。于是,猫猫意识到这是我...

    Python猫
  • Go 的垃圾回收机制在实践中有哪些需要注意的地方?

    之前回答问题的时候Go还处在1.1版本,到了1.2和1.3,Go的GC跟踪命令和GC内部实现已经有一些变化,并且根据评论中的反馈,这边一并做补充说明。 Go ...

    李海彬
  • 【python进阶】Garbage collection垃圾回收2

    前言 在上一篇文章【python进阶】Garbage collection垃圾回收1,我们讲述了Garbage collection(GC垃圾回收),画说Rub...

    Angel_Kitty
  • GC和垃圾回收器其二

    CMS是并行标记回收器,使用标记-清除算法进行收集。适用于对时延要求较高的在线服务,不接受长时间停顿的那种。但是如果服务运行较长时间,会造成严重的内存碎片。

    春哥大魔王
  • 作为程序员,你觉得C/C++、Java哪门编程语言更强大?

    作为一个C/C++ java都做过项目的老码农,编程语言没有谁强大谁弱小这么一讲,每种编程都有其自身发挥的优势,在软件发展早期阶段C/C++重要性会更高一点,毕...

    程序员互动联盟
  • 腾讯云标准型 S4实例和标准型 S5服务器区别及如何选择?

    腾讯云标准型 S4实例和标准型 S5服务器区别及如何选择?这两款服务器类型仅支持在私有网络中启动,是不提供公网使用的,也就是没有公网 IP地址。

    魏艾斯博客www.vpsss.net
  • Python 字符串子串定位性能比较

    本文想探讨的是在给定了key字段在字段列表中开始下标和key字段个数后,如何在整行字符串中定位到key字符串的起始位置。

    邵靖
  • Python 测试基础

    你怎么知道自己编写的程序管用呢?能指望你在任何时候编写的代码都没有缺陷吗?恕我直言,我想这不太可能。诚然,在大多数情况下使用 Python 都很容易编写出正确的...

    不可言诉的深渊

扫码关注云+社区

领取腾讯云代金券