最近在阅读《垃圾回收的算法与实现》,里面将到了一些常用的垃圾回收(Garbage Collect)算法,如:标记-清除、引用计数、分代回收等等。
后面讲到了 Python 的垃圾回收策略,在此记录一下。
吞吐量
吞吐量为单位时间内的GC出来能力。计算公式为:GC处理的堆容量 / GC花费的时间。
通常,人们都喜欢吞吐量高的GC算法。
最大暂停时间
GC执行过程中令用户线程暂停的最长时间。如果GC时间过长,会影响程序的正常执行。
较大的吞吐量和较短的最大暂停时间往往不可兼得。
堆使用效率
GC是自动内存管理功能,所以理想情况是在GC时不要占用过量的堆空间。
影响堆使用效率的两个因素是:头的大小和堆的用法。
可用的堆越大,GC运行越快;相反,越想有效地利用有限的堆,GC花费的时间就越长。
访问的局部性
具有引用关系的对象之间通常很可能存在连续访问的情况。这在多数程序中都很常见,称为“访问的局部性”。
考虑到访问局部性,把具有引用关系的对象安排在堆中较近的位置,能够提高数据的利用率。
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 在进行内存分配时并不是简单的调用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字节的,并且生命周期很短,例如:
for x in range(100): print(x)
这个过程中如果频繁调用malloc
和free
,效率就会很低。Python 通过增加第1层和第2层,并在第一层中会事先从第0层申请一些空间保留管理起来,当分配对象内存小于256时,使用这部分空间。
Python 管理的内存结构有3个部分:block
、pool
、arena
,它们之间的关系如下:
为了避免频繁调用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 内实现引用计数法,其实就是对各对象的引用数量变更做相应的操作,如果对象的引用数量增加,就在计数器上加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应该由谁去负责呢?
这里就涉及到“引用的所有权”, 即谁持有引用的所有权,谁就得承担在不需要此引用时将对象的引用计数器减量的责任。
PyObject *dict = PyDict_New(); /* 进行一些操作, 结束后*/ Py_DECREF(dict); dict = NULL;
PyObject * PyTuple_GetItem(register PyObject *op, register Py_ssize_t i) { /*检查操作*/ return ((PyTupleObject *)op) -> ob_item[i] }
PyObject *tuple, *dict; tuple = PyTuple_New(3); dict = PyDict_New(); /*用所有权返回给了dict*/ PyTuple_SetItem(tuple, 0, dict); /*引用所有权被tuple占据了*/ dict = NULL;
引用计数法有一个缺陷,无法解决循环引用问题,当两个对象之间相互引用,引用计数无法清零,即无法进行 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[n].head) PyGC_Head *_PyGC_generation0 = GEN_HEAD(0); /* 0代的容器对象 */
一开始所有的容器对象都连接着0代的对象。当0代容器对象经过1次循环引用垃圾回收,仍然存活下来的对象晋升为1代;再次从循环引用垃圾回收过程中存活下来的对象晋升为2代。
在循环引用垃圾回收过程中,我们会将有终结器
的对象从不可达链表中移除
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 提供了全局变量garbage
,让开发者内从英语程序的角度来去移除对象的循环引用。
import gc gc.grabage
Python 的 GC 分为两部分:
引用计数算法
来管理常规对象的垃圾回收,并通过优化的内存结构来尽可能减少 GC分代+清除-标记
来管理循环引用对象的垃圾回收