AI智能
改变未来

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_EXTRAPy_ssize_t ob_refcnt;struct _typeoject *ob_type;} PyObject;

其中的

ob_refcnt

成员负责维持引用计数如此,所有的内置型结构在都在开头保留了

PyObject

结构体来维护引用计数。

内存分配器

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)

这个过程中如果频繁调用

malloc

free

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

Python 管理的内存结构有3个部分:

block

pool

arena

,它们之间的关系如下:

  • 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#endifstatic PyDictObject *free_list[PyDict_MAXFREELIST];static int numfree = 0;

在分配字典对象时,会先检查空闲列表

free_list

是否有可用对象,如果已被用尽,再去通过第2层

PyObject_GC_New

去申请对象。

整体下来 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 voidtupledealloc(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

函数

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

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

Py_DECREF_Py_Dealloc              ———— 减量操作tupledeallocPyObject_GC_Del      ———— 元组释放处理PyObject_FREEPyObject_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 通过

部分标记-清除算法

来解决。

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

比如下图,左侧的三个对象之前存在循环引用,导致正常的引用计数无法将他们回收我们先将他们当前的引用计数复制到另一块区用于后面操作有一个前提:Python 对象在生成时会将其自身连接到一个

对象链表

中(通过双向指向相互连接),图中用双向箭头表示基于此,我们遍历

对象链表

中的所有对象,对他们所有引用的对象进行拷贝计数减一现在进行

标记

操作了,我们将所有经过上步处理后拷贝计数仍然大于0的对象标记为“可达对象”,即有其他活动对象引用他们,并将他们所引用的对象也标记为可达对象,连接到可达对象链表中;然后将拷贝计数为0的对象记为“不可达对象”,连接到不可达对象链表。可以看到,不可达对象中即为循环引用的对象,接下来进行

清除

操作,我们将不可达对象链表中的对象释放内存,将可达对象链表中的对象重新连接会

对象链表

到此,我们完成了对循环引用对象的垃圾回收。

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

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

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;

正如前面所说,容器对象生成时,会被连接到

对象链表

,以字典对象为例,看一下他生成时代码

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

_PyObject_GC_TRACK

负责连接到

对象链表

中的操作

#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 将上面提到的容器对象链表划分为“0代”、“1代”、“2代”,通过下面的结构体管理

struct gc_generation {PyGC_Head head;   /* 头指正 */int threshold;    /* 开始GC的阈值 */int count;        /* 改代的对象数 */}# define GEN_HEAD(n) (&generations.head)PyGC_Head *_PyGC_generation0 = GEN_HEAD(0); /* 0代的容器对象 */

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

移除特例

在循环引用垃圾回收过程中,我们会将有

终结器

的对象从不可达链表中移除

static voidmove_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 提供了全局变量

garbage

,让开发者内从应用程序的角度来去移除对象的循环引用。

import gcgc.grabage

总结

Python 的 GC 分为两部分:

  • 通过
    引用计数算法

    来管理常规对象的垃圾回收,并通过优化的内存结构来尽可能减少 GC

  • 通过
    分代+清除-标记

    来管理循环引用对象的垃圾回收

赞(0) 打赏
未经允许不得转载:爱站程序员基地 » Python 垃圾回收总结