Python 垃圾回收总结

前言

最近在阅读《垃圾回收的算法与实现》,里面将讲到了一些常用的垃圾回收(Garbage Collect)算法,如:标记-清除、引用计数、分代回收等等。
后面讲到了 Python 的垃圾回收策略,在此记录一下。

衡量 GC 性能的四要素

  1. 吞吐量
    吞吐量为单位时间内的GC出来能力。计算公式为:GC处理的堆容量 / GC花费的时间。
    通常,人们都喜欢吞吐量高的GC算法。
  2. 最大暂停时间
    GC执行过程中令用户线程暂停的最长时间。如果GC时间过长,会影响程序的正常执行。
    较大的吞吐量和较短的最大暂停时间往往不可兼得。
  3. 堆使用效率
    GC是自动内存管理功能,所以理想情况是在GC时不要占用过量的堆空间。
    影响堆使用效率的两个因素是:头的大小和堆的用法。
    可用的堆越大,GC运行越快;相反,越想有效地利用有限的堆,GC花费的时间就越长。
  4. 访问的局部性
    具有引用关系的对象之间通常很可能存在连续访问的情况。这在多数程序中都很常见,称为”访问的局部性”。
    考虑到访问局部性,把具有引用关系的对象安排在堆中较近的位置,能够提高数据的利用率。

Python 使用引用计数的 GC 的算法,引用计数算法的优势是可以减短 最大暂停时间,缺陷是在维护计数的增量和减量上面临很大的挑战(如果忘记执行减量操作就会造成对象没有释放)。

引用计数存在哪里

对于 Python 的数据,像 List、Set、Tuple、Dict、Str、Int,在其底层的数据结构中,都会有一个 PyObject类型的成员,用来维护对象的引用计数

typedef struct _object {
    _PyObject_HEAD_EXTRA
    Py_ssize_t ob_refcnt;
    struct _typeoject *ob_type;
} PyObject;

其中的 ob_refcnt成员负责维持引用计数
如此,所有的内置型结构在都在开头保留了 PyObject结构体来维护引用计数。

Python 垃圾回收总结

内存分配器

Python 在进行内存分配时并不是简单的调用 malloc/free函数来向操作系统请求内存的。而是出于效率考虑尽可能减少系统调用,将内存分配器分成了3层。

    Object-specific allocators
    _____   ______   ______       ________
   [ int ] [ dict ] [ list ] ... [ string ]       Python core         |
+3 | <----- object-specific memory -----> | <-- non-object memory --> |
    _______________________________       |                           |
   [   Python's object allocator   ]      |                           |
+2 | ####### Object memory ####### | <------ internal buffers ------> |
    ______________________________________________________________    |
   [          Python's raw memory allocator (PyMem_ API)          ]   |
+1 | <----- python memory (under pymem manager's control) ------> |   |
    __________________________________________________________________
   [    Underlying general-purpose allocator (ex: C library malloc)   ]
 0 | <------ virtual memory allocated for the python process -------> |
   =========================================================================
    _______________________________________________________________________
   [                OS-specific Virtual Memory Manager (VMM)               ]
-1 | <--- kernel dynamic storage allocation & management (page-based) ---> |
    __________________________________   __________________________________
   [                                  ] [                                  ]
-2 | <-- physical memory: rom ram --> | | <-- secondary storage (swap) --> |

</--></--></---></------></-----></------></--></----->

第0层到第3层是 Python 实现管理的分布器层级,如果以字典对象为例,

PyDict_New()             ----3层
  PyObject_GC_New()      ----2层
  PyObject_Malloc()      ----2层
    new_arena()          ----1层
      malloc()           ----0层

第0层就是直接调用操作系统提供的分配函数,如 malloc。Python 并不是所有的对象生成都调用第0层,而是根据要分配内存的大小来选择不同的分配方案:

  • 申请的内存大于256字节,使用第0层
  • 申请的内存小于256字节,使用第一层和第二层

基于经验,我们是编码过程中使用的大量对象都是小于256字节的,并且生命周期很短,例如:

for x in range(100):
  print(x)

这个过程中如果频繁调用 mallocfree,效率就会很低。Python 通过增加第1层和第2层,并在第一层中会事先从第0层申请一些空间保留管理起来,当分配对象内存小于256时,使用这部分空间。

Python 管理的内存结构有3个部分: blockpoolarena,它们之间的关系如下:

Python 垃圾回收总结
  • arena 用来管理存储 pool
  • pool 用来管理存储 block
  • block 内存申请者可用的最小单位

为了避免频繁调用 malloc()free(),第1层的分配器会以最大的单位 arena 来保留内存。pool 是用于有效管理空的block的单位。

第2层的分配器负责管理 pool 内的 block。将 block 的开头地址返回给申请者,并释放 block 等。
分配器会将 pool 会按照8字节的倍数的大小来分割后产出若干个 block,如:8字节的 block、16字节的 block、24字节的 block、… 256字节的 block。申请内存时,会返回适合大小的 block。

第3层是对象特有的分配器,Python 中各种内置类型:如:list、dict 等,又会有各自特有的分配器,比如 dict 的分配器长这样:

#ifndef PyDict_MAXFREELIST
#define PyDict_MAXFREELIST 80
#endif
static PyDictObject *free_list[PyDict_MAXFREELIST];
static int numfree = 0;

在分配字典对象时,会先检查空闲列表 free_list是否有可用对象,如果已被用尽,再去通过第2层 PyObject_GC_New去申请对象。

整体下来 Python 生成字典对象时的交互如下:

Python 垃圾回收总结

引用计数法

Python 内实现引用计数法,其实就是对各对象的引用数量变更做相应的操作,如果对象的引用数量增加,就在计数器上加1,反过来如果引用数量减少,就在计数器上减去1。

增量操作

实际的计数操作由宏 Py_INCREF来实现

#define Py_INCREF(op) (                  \
    ((PyObject*)(op))->ob_refcnt++)

ob_refcnt的类型在32位环境下是 int 型,在64位环境下是 long 型。因为有符号位,所以只有一半数值能用非负整数表示。但是因为指针基本上都是按4字节对齐的,所以即使引用计数器被所有指针引用,也不会溢出。
设计成允许存在负数是为了方便调试,当引用计数器存在负数数,就有减量操作过度或者增量操作遗留的可能。

减量操作

实际的计数操作由宏 Py_DECREF来实现

#define Py_DECREF(op)                          \
    if (--((PyObject*)(op))->ob_refcnt != 0)        \
        _Py_CHECK_REFCNT(op)                  \
     else                                \
        _Py_Dealloc((PyObject*)(op))

define _Py_Dealloc(op) (              \
        (*Py_TYPE(op)->tp_dealloc)((PyObject*)(op)))

先将计数器将量,如果不为0,调用宏 _Py_CHECK_REFCNT检查引用计数器是否变为了负数。如果计数器为0,那么就调用宏 _Py_Dealloc,通过 Py_TYPE识别对象的类型并调用对应的负责释放各个对象的函数指针,比如:负责释放元组对象的函数指针是 tupledealloc

static void
tupledealloc(register PyTupleObject *op)
{
    register Py_ssize_t i;
    register Py_ssize_t len = Py_Size(op);

    if (len > 0) {
        i = len;
        /* 将元组内的元素进行减量 */
        while(--i >= 0)
            Py_XDECREF(op->ob_item[i]);
    }
    /* 释放元组对象 */
    Py_TYPE(op)->tp_free((PyObject *)op);

    Py_TRASHCAN_SAFE_END(OP);
}

成员 tp_free存着对各个对象的释放处理函数指针,大部分在其内部都是调用 PyObect_GC_Del函数

void
PyObject_GC_Del(void *op)
{
    PyGC_Head *g = AS_GC(op);
    /* ... */
    Pyobject_FREE(g)
}

元组的减量操作调用图如下:

Py_DECREF
  _Py_Dealloc              ———— 减量操作
    tupledealloc
      PyObject_GC_Del      ———— 元组释放处理
        PyObject_FREE
          PyObject_Free    ———— 释放内存

引用的所有权

上面已经阐明了引用计数的核心操作就是计数+1和计数-1。需要明确的是,谁来去负责进行操作,比如:A 对象通过调用函数 func1获得了 B 对象,那么 B 对象的引用计数+1应该由谁去负责呢?
这里就涉及到”引用的所有权”, 即谁持有引用的所有权,谁就得承担在不需要此引用时将对象的引用计数器减量的责任。

  1. 传递引用所有权(返回值)
    即函数方把引用的所有权和返回值一起交给调用方。由函数的调用方在引用结束时负责执行减量操作。对象生成时,会把引用所有权交给调用方,比如:字典对象的生成
PyObject *dict = PyDict_New();
/* 进行一些操作, 结束后*/
Py_DECREF(dict);
dict = NULL;
  1. 出借引用的所有权(返回值)
    即函数方只把返回值交给调用方,至于引用的所有权则只是出借而已,调用方不能对引用计数进行减量操作。负责从”集合”中取出元素的函数,都是出借所有权,比如:元组获取指定索引的函数
PyObject *
PyTuple_GetItem(register PyObject *op, register Py_ssize_t i)
{
    /*检查操作*/
    return ((PyTupleObject *)op) -> ob_item[i]
}
  1. 占据引用的所有权(参数)
    即调用方把参数传递给函数时,函数方有时会占据这个参数的引用所有权,此时由函数方负责该参数的引用管理事宜。往链表中追加元素的函数都会占据参数的引用所有权,比如:向元组指定位置增加元素的函数
PyObject *tuple, *dict;

tuple = PyTuple_New(3);
dict = PyDict_New();    /*用所有权返回给了dict*/
PyTuple_SetItem(tuple, 0, dict);    /*引用所有权被tuple占据了*/
dict = NULL;
  1. 出借引用所有权(参数)
    即调用方把参数的引用所有权借给函数方。当函数的调用方要出借引用的所有权时,从把对象交给函数之后直到函数执行结束为止,这段时间调用方都必须保留指向对象的引用的所有权。

循环引用垃圾回收

引用计数法有一个缺陷,无法解决循环引用问题,当两个对象之间相互引用,引用计数无法清零,即无法进行 GC。因此,对于循环引用,Python 通过 &#x90E8;&#x5206;&#x6807;&#x8BB0;-&#x6E05;&#x9664;&#x7B97;&#x6CD5;来解决。

算法原理:部分标记-清除算法

比如下图,左侧的三个对象之前存在循环引用,导致正常的引用计数无法将他们回收

Python 垃圾回收总结
我们先将他们当前的引用计数复制到另一块区用于后面操作
Python 垃圾回收总结
有一个前提:Python 对象在生成时会将其自身连接到一个 &#x5BF9;&#x8C61;&#x94FE;&#x8868;中(通过双向指向相互连接),图中用双向箭头表示
Python 垃圾回收总结
基于此,我们遍历 &#x5BF9;&#x8C61;&#x94FE;&#x8868;中的所有对象,对他们所有引用的对象进行拷贝计数减一
Python 垃圾回收总结
现在进行 &#x6807;&#x8BB0;操作了,我们将所有经过上步处理后拷贝计数仍然大于0的对象标记为”可达对象”,即有其他活动对象引用他们,并将他们所引用的对象也标记为可达对象,连接到可达对象链表中;然后将拷贝计数为0的对象记为”不可达对象”,连接到不可达对象链表。
Python 垃圾回收总结
可以看到,不可达对象中即为循环引用的对象,接下来进行 &#x6E05;&#x9664;操作,我们将不可达对象链表中的对象释放内存,将可达对象链表中的对象重新连接会 &#x5BF9;&#x8C61;&#x94FE;&#x8868;
Python 垃圾回收总结
到此,我们完成了对循环引用对象的垃圾回收。

并不是所有对象都会发生循环引用

引起循环引用的根因是有些对象可能保留的指向其他对象的引用,对于这类对象,我们称之为”容器对象”。
像元组、列表和字典等,都属于容器对象,只需要对这些容器对象解决循环引用的问题。容器对象中都被分配了用于循环引用垃圾回收的头结构体。

tyepdef union _gc_head {
    struct {
        union _ge_head *gc_next; /*用于双向链表*/
        union _gc_head *gc_prev; /*用于双向链表*/
        Py_ssize_t gc_refs;      /*用于复制计数*/
    } gc;
    long double dummy;
} PyGC_Head;

正如前面所说,容器对象生成时,会被连接到 &#x5BF9;&#x8C61;&#x94FE;&#x8868;,以字典对象为例,看一下他生成时代码

PyObject *
PyDict_New(void)
{
    regiseter PyDictObject *mp;
    /*生成对象的操作*/
    _PyObject_GC_TRACK(mp);
    return (PyObject *)mp;

}

_PyObject_GC_TRACK负责连接到 &#x5BF9;&#x8C61;&#x94FE;&#x8868;中的操作

#define _PyObject_GC_TRACK(o) do { \
    PyGC_Head *g = _Py_AS_GC(o); \
    g->gc.gc_refs = _PyGC_REFS_REACHABLE;  \
    g->gc.gc_next = _PyGC_generation0; \
    g->gc.gc_prev = _PyGC_generation0->gc.gc_prev; \
    g->gc.gc_prev ->gc.gc_next = g;  \
    _PyGC_generation0->gc.gc_prev = g; \
    } while (0);

Python 垃圾回收总结

容器对象分代管理

Python 将上面提到的容器对象链表划分为”0代”、”1代”、”2代”,通过下面的结构体管理

struct gc_generation {
    PyGC_Head head;   /* 头指正 */
    int threshold;    /* 开始GC的阈值 */
    int count;        /* 改代的对象数 */
}

define GEN_HEAD(n) (&generations[n].head)

PyGC_Head *_PyGC_generation0 = GEN_HEAD(0); /* 0代的容器对象 */

一开始所有的容器对象都连接着0代的对象。当0代容器对象经过1次循环引用垃圾回收,仍然存活下来的对象晋升为1代;再次从循环引用垃圾回收过程中存活下来的对象晋升为2代。

Python 垃圾回收总结

移除特例

在循环引用垃圾回收过程中,我们会将有 &#x7EC8;&#x7ED3;&#x5668;的对象从不可达链表中移除

static void
move_finalizers(PyGC_Head *unreachable, PyGC_Head *finalizers)
{
    PyGC_Head *gc;
    PyGC_Head *next;

    for (gc = unreachable->gc.gc_next; gc != unreachable; gc = next) {
        PyObject *op = FROM_GC(gc);
        next = gc->gc.gc_next;
        if (has_finalizer(op)) {
            gc_list_move(gc, finalizers);
            gc->gc.gc_refs = GC_REACHABLE;
        }
    }
}

之所以如此,是因为想要释放循环引用的有终结器的对象是非常麻烦的。我们无法确定先释放那个对象时合理的,如果先将第1个对象释放,再释放第二个对象时执行的终结器引用了第一个对象怎么办?

Python 垃圾回收总结

对于这些含有终结器的循环引用垃圾对象, Python 提供了全局变量 garbage,让开发者内从应用程序的角度来去移除对象的循环引用。

import gc
gc.grabage

总结

Python 的 GC 分为两部分:

  • 通过 &#x5F15;&#x7528;&#x8BA1;&#x6570;&#x7B97;&#x6CD5;来管理常规对象的垃圾回收,并通过优化的内存结构来尽可能减少 GC
  • 通过 &#x5206;&#x4EE3;+&#x6E05;&#x9664;-&#x6807;&#x8BB0;来管理循环引用对象的垃圾回收

Original: https://www.cnblogs.com/Zioyi/p/15418792.html
Author: Zioyi
Title: Python 垃圾回收总结

原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/575860/

转载文章受原作者版权保护。转载请注明原作者出处!

(0)

大家都在看

  • kafka详解(一)–kafka是什么及怎么用

    kafka是什么 在回答这个问题之前,我们需要先了解另一个东西–event streaming。 什么是event streaming 我觉得, event strea…

    数据库 2023年6月6日
    082
  • 在Linux中使用crontab

    查看已存在的任务 查看crontab 输入命令: cat /etc/crontab 在设定编辑之前都建议列出服务查看一下: crontab -l 语法: **** user_nam…

    数据库 2023年6月14日
    084
  • 工厂模式

    工厂模式是java中最常用的设计模式之一,这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。在工厂模式中,我们在创建对象时不会对客户端暴露创建逻辑,并且是通过使用一…

    数据库 2023年6月11日
    088
  • 【java框架】SpringBoot(10) — SpringBoot巧用 @Async提升API接口并发能力

    1.背景 异步调用几乎是处理高并发Web应用性能问题的万金油,那么什么是”异步调用”? “异步调用”对应的是”同步调用&…

    数据库 2023年6月6日
    0105
  • Java 笔记(全)

    ​ 标识符:给类或者变量类的方法属性命名都是标识符 ​ 组成必须是: &#x5B57;&#x6BCD;&#x3001;&#x6570;&#x…

    数据库 2023年6月11日
    076
  • 正则表达式

    正则表达式:REGEXP,REGular EXPression。正则表达式分为两类: Basic REGEXP(基本正则表达式 Extended REGEXP(扩展正则表达式) 元…

    数据库 2023年6月15日
    0141
  • 第十六章 Spring动态代理详解

    MethodInterceptor(方法拦截器) public class Arround implements MethodInterceptor { /* invoke方法: …

    数据库 2023年6月14日
    080
  • Java学习-第一部分-第三阶段-第二节:反射

    反射 笔记目录:(https://www.cnblogs.com/wenjie2000/p/16378441.html) 一个需求引出反射 请看下面的问题 根据配置文件 re.pr…

    数据库 2023年6月11日
    069
  • 如何在电脑上配置Vue开发环境

    一,开发环境 : Node JS(npm) Visual Studio Code(前端IDE) 安装NodeJS 下载地址: nodejs中文网 Visual Studio Cod…

    数据库 2023年6月11日
    095
  • podman(无根用户管理podman)

    用户操作在允许没有root特权的用户运行Podman之前,管理员必须安装或构建Podman并完成以下配置cgroup V2Linux内核功能允许用户限制普通用户容器可以使用的资源,…

    数据库 2023年6月14日
    092
  • LeetCode 344. 反转字符串

    编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出。 //输入一个字符串,输出它的倒序字符串 input: Hello output: olleH …

    数据库 2023年6月11日
    084
  • 23种设计模式之模板模式

    文章目录 概述 模版模式的优缺点 * 优点 缺点 模版模式的使用场景 模板模式的结构和实现 * 模式结构 模式实现 总结 ; 概述 模板模式指:一个抽象类中,有一个主方法,再定义1…

    数据库 2023年6月6日
    0104
  • Java List分批处理

    工作中经常遇到分批处理的问题,比如将一个List列表中的数据分批次保存至数据库中。如果列表中数据条目很大,比如1000万条以上,mysql中 max_allowed_packet …

    数据库 2023年6月14日
    098
  • 对于Java循环中的For和For-each,哪个更快

    Which is Faster For Loop or For-each in Java 对于Java循环中的For和For-each,哪个更快 通过本文,您可以了解一些集合遍历技…

    数据库 2023年6月11日
    088
  • 整理记录一些好用的随机图API

    最近自己博客使用的随机图API有些不稳定,自己又去搜集了一些有意思的随机图API,这里做一个整理记录 注意!!!本文链接最后测试时间—-2021年11月21日 主题作者…

    数据库 2023年6月11日
    076
  • 没有 Cgroups,就没有 Docker

    Cgroups 是什么? Cgroups 是 control groups 的缩写,是 Linux 内核提供的一种可以 限制、 记录、 隔离进程组(progress groups)…

    数据库 2023年6月6日
    0111
亲爱的 Coder【最近整理,可免费获取】👉 最新必读书单  | 👏 面试题下载  | 🌎 免费的AI知识星球