Python 垃圾回收机制

但愿樽中九酝满,莫惜床头百个钱。 Posted by hstk30 on February 21, 2022

垃圾回收机制

理论知识

垃圾收集器(garbage collector)将内存视为一张有向可达图(reachability graph),其形式如下图

directed-graph

将节点分为根节点(root node) 和堆节点(heap node)。堆节点对应于在堆中的一个已分配块。有向边p -> q 表示p 有对q 的引用。 根节点持有对堆节点内存块的引用,这些根节点可以是寄存器、栈里的变量,或者是虚拟内存中读写数据区域内的全局变量。

当存在一条从任意根节点出发并到达p 的有向路径时,我们说节点p可达的(reachable)。在任何时刻,不可达节点对应于垃圾, 是不能被应用再次使用的。垃圾收集器的角色是维护可达图的某种表示,并通过释放不可达节点且将它们返回给内存管理器(不管是应用的还是系统的), 来定期地回收它们。

综上,垃圾收集器(只针对 Mark&Sweep 标记&清除 算法)需要维护一个已分配的内存块的有向图, 收集操作由 标记(mark) 阶段和 清除(sweep) 阶段组成。

  • 标记阶段:
    • 遍历节点,找出所有根节点
    • 顺着根节点标记出可达的节点。
  • 清除阶段:释放每个未被标记为可达的节点。

Python 的垃圾回收机制

Python 使用

  • 引用计数
  • 分代回收(Generational garbage collector)

两种技术来实现垃圾回收

引用计数

每个Python 对象都有一个ob_refcnt 属性,用来记录引用这个对象的次数,当一个对象的引用被创建或复制时, 引用计数加1;当一个对象的引用被销毁时,引用计数减1。 Python 通过以下的两个 来实现增减引用次数,并在引用次数为零时,调用这个对象的类型对象的析构函数dealloc 进行释放。

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

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

这非常简单,但也带来几个问题:

  • 每个对象都多了一个ob_refcnt 属性来占用内存(小问题)
  • 性能与引用计数的变动次数成正比,每次赋值都需要++ 操作(小问题), 引用次数为0时,对于List Dict 等对象要遍历所包含的元素,逐个释放(Python 使用了大量内存池机制来弥补)
  • 直接导致全局解释锁(GIL, Global Interpreter Lock),因为当多线程对同一个全局对象进行增减引用次数并不是原子操作,导致ob_refcnt 结果不正确。
  • 循环引用(大问题,需要解决)

当然,也是有优点的:

  • 简单,真的很简单
  • 实时,引用计数为0就释放。而不是等到内存不够了或时间片用完了,这个时候内存中有大量的已分配对象需要遍历,往往导致程序短暂的停顿。

循环引用

In [1]: l1 = []
In [2]: l2 = []
In [3]: l3 = []
In [4]: l1.append(l2)
In [5]: l2.append(l3)
In [6]: l3.append(l1)

这样就直接导致循环引用,l1.ob_refcnt == 1 l2.ob_refcnt == 1 l3.ob_refcnt == 1 而没有其他变量对这3个对象的引用, 所以引用计数在这种情况下失效了,它检测不出循环引用的存在:显然这三个对象是垃圾(garbage),应该被释放才对。

解决方法

导致这样的主要原因是这3个变量各自的引用计数没有展示出我们希望表现的样子,我们希望这个引用计数应该记录来自外部的引用。 我们希望达到下图的效果:

remove-ring

达到这个效果的思路就是: 解铃还须系铃人。既然计数是因为引用才增加的,那么让它再因引用而减少吧。 图的上面那部分中,本来各自的引用计数都为1,遍历container 对象内的引用,使遍历到的对象的引用计数相应减少1。 因此,最后都变成了0。此时,可以将上面三个变量标记为 不可达的

而图的下面那部分中,因为有一个来自外部的引用,因此在经过一轮container 对象内的引用遍历后,上顶角的对象的引用计数为1 (外部的引用在对象内遍历不到),因此可以暂时把两个底角上的对象标记为 不可达的,而顶角的对象依旧是 可达的

这样我们就将一个有向图中的所有节点分成 可达的不可达的 两类。 而对于 可达的 对象,它所引用的对象也应该是 可达的, 对应图的下半部分中,虽然底角的对象被标记为 不可达的,但是因为有 可达的 对象的引用, 因此,还需要再遍历一遍 可达的 对象所引用的对象,将它们都设置为可达的

综上,总结为以下几步:

  1. 遍历所有节点,对每个节点,使这个节点所引用对象的引用次数减1
  2. 再遍历一遍所有节点,根据节点的引用次数将这些节点分为 可达的不可达的 两类

伪代码如下:

for node in {n0, n1, n2...}:
    for element in node:
         element.ob_refcnt -= 1
      
for node in {n0, n1, n2...}:
    if node.ob_refcnt == 0 and node not mark reachable:
        mark node as unreachable

    if node.ob_refcnt > 0:
        mark node as reachable
        for element in node:
            mark element as reachable

通过上面的方法,我们就判断出哪些对象是因为循环引用而没被引用计数回收的,接下来就只要对这些对象进行清理就行了。

分代回收(Generational garbage collector)

Given a realistic amount of memory, efficiency of simple copying garbage collection is limited by the fact that the system must copy all live data at a collection. In most programs in variety of languages, most objects live a very short time, while a small percentage of then live much longer. While figures vary from language to language and program to program, usually between 80 and 98 precent of all newly-allocated heap objects die within a few million instructions, or before another megabyte has been allocated; the majority of objects die even more quickly, within tens of kilobytes of allocation.

上面是说:大多数对象的寿命都很短,只有一小部分的对象寿命比较长,通常在所有新分配的对象中,有80%到98%在几百万条指令内就死亡(成为垃圾)了。

这也是很直观的,在函数调用中,除了出、入参数外,大部分的局部变量、临时变量都可以在这次调用后被销毁、回收。 而那些在主函数中声明的变量、全局变量的生命周期往往和程序相同。

分代回收机制正是基于上面的事实。其总体思想是:将系统中的所有内存块根据其存活时间划分为不同的集合,每一个集合称为一个“代”, 垃圾回收的频率随着“代”的存活时间的增大而减小,也就是说,活得越长的对象,就越不可能是垃圾,就应该越少去收集。

源码分析

维护一个有向图

无非就是链表、双向链表或者树的结构,Python 实现中使用的双向链表。

typedef union _gc_head {
    struct {
        union _gc_head *gc_next;
        union _gc_head *gc_prev;
        Py_ssize_t gc_refs;
    } gc;
    long double dummy;  /* force worst-case alignment */
} PyGC_Head;

因为这个机制是只针对循环引用的,而循环引用只发生在 container 容器对象,如List Dict class等中,而int str 等对象是不可能发生循环引用的。 因此,上面结构只需要给container对象加上即可。如图:

gc-object

在原来的基础上带了个gc head帽子,表示这是一个需要gc 监控的container 对象。

在创建每个container 对象时,Python 使用PyObject_GC_New,而创建非container 对象时则使用PyObject_Malloc

PyObject *
_PyObject_GC_Malloc(size_t basicsize) 
{
    PyObject *op;
    PyGC_Head *g = (PyGC_Head *)PyObject_MALLOC(sizeof(PyGC_Head) + basicsize);
    g->gc.gc_refs = GC_UNTRACKED;
    ...
    return op;
}

PyObject *
_PyObject_GC_New(PyTypeObject *tp) 
{
    PyObject *op = _PyObject_GC_Malloc(_PyObject_SIZE(tp));
    if (op != NULL)
        op = PyObject_INIT(op, tp);
    return op;
}

PyObject_GC_New 只是调用PyObject_Malloc 多分配一个PyGC_Head 结构大小的内存。

 /* Get an object's GC head */
 #define AS_GC(o) ((PyGC_Head *)(o)-1)  

 /* Get the object given the GC head */
 #define FROM_GC(g) ((PyObject *)(((PyGC_Head *)g)+1))

这两个 通过加减一个PyGC_Head 结构大小来实现PyObjectPyGC_Head 之间的切换。

struct gc_generation {
    PyGC_Head head;
    int threshold; /* 这一“代”的最大阈值,当count >= threshold 时就对这“代”进行垃圾回收 */
    int count; /* 对于第0代而言,count 表示进行分配的次数,而对于更高的代来说,表示的是在这代上进行collect(i) 的次数 */
};

#define NUM_GENERATIONS 3
#define GEN_HEAD(n) (&generations[n].head)

static struct gc_generation generations[NUM_GENERATIONS] = {
 { { { GEN_HEAD(0), GEN_HEAD(0), 0 } },  700,     0},  /* 称为“青生代” */
 { { { GEN_HEAD(1), GEN_HEAD(1), 0 } },   10,     0},  /* 称为“中生代” */
 { { { GEN_HEAD(2), GEN_HEAD(2), 0 } },   10,     0},  /* 称为“老生代” */
};

PyGC_Head *_PyGC_generation0 = GEN_HEAD(0);

这就是“有向图”的结构,也是其称为分代回收的原因。从上面代码上可以看出它分为3代。每一代都是一个双向链表的结构。 它的初始状态,如图:

generator-init

/* 简单的双向列表操作,将对象`o` 加入到`_PyGC_generation0` “青生代”中*/
 #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);

加入若干container 对象后,“有向图”可能长这样:

generator-grow

随着不断的创建container 对象,

/* true if we are currently running the collector */
static int collecting = 0;

PyObject *
_PyObject_GC_Malloc(size_t basicsize) 
{
    PyObject *op;
    PyGC_Head *g = (PyGC_Head *)PyObject_MALLOC(sizeof(PyGC_Head) + basicsize);
    g->gc.gc_refs = GC_UNTRACKED;
    generations[0].count++; /* number of allocated GC objects */
    if (generations[0].count > generations[0].threshold &&
            enabled &&
            generations[0].threshold &&
            !collecting &&
            !PyErr_Occurred()) {
        collecting = 1;
        collect_generations();  /* 进行垃圾回收 */
       collecting = 0;
    }
    op = FROM_GC(g);
    return op;
}

变量collecting 是一个全局的变量标识当前Python 解释器是否在进行垃圾回收(因为GIL存在,所以无所谓变量可不可重入)。 可以看到,当generations[0].count > generations[0].threshold 的时候,就调用collect_generations()进行垃圾回收。

标记阶段

static Py_ssize_t
collect_generations(void) 
{
    int i;
    Py_ssize_t n = 0;
    /* 从最老的“代”开始重新判断要从哪个代开始回收 */
    for (i = NUM_GENERATIONS-1; i >= 0; i--) {
        if (generations[i].count > generations[i].threshold) {
            n = collect(i);
            break;
        }
    }
    return n;
}

上面的函数找出满足count 值越界的最“老”的那一“代”,对这“代”进行收集(collect)操作。因为,“代”上的结构是双向链表, 因此,就可以应用 循环引用的解决方法 进行垃圾标记和回收。 Python 实现中先将比要收集的“代”要年轻的“代”都合并到这“代”上(相当于让年轻的“代”变老),假设对1代 进行收集,这将0代 合到1代 上。

/* 简单的链表操作将两条双向链表合并 */
static void
gc_list_merge(PyGC_Head *from, PyGC_Head *to) 
{
	PyGC_Head *tail;
	if (!gc_list_is_empty(from)) {
		tail = to->gc.gc_prev;
		tail->gc.gc_next = from->gc.gc_next;
		tail->gc.gc_next->gc.gc_prev = tail;
		to->gc.gc_prev = from->gc.gc_prev;
		to->gc.gc_prev->gc.gc_next = to;
	}
	gc_list_init(from);
}

在得到要处理的有向图(双向链表)后,Python 先遍历一边这“代”上要进行收集的对象,将对象的ob_refcnt 赋值给PyGC_Head.gc_refs , 防止在接下来的操作中对ob_refcnt 产生副作用。

static void
update_refs(PyGC_Head *containers)
{
	PyGC_Head *gc = containers->gc.gc_next;
	for (; gc != containers; gc = gc->gc.gc_next) {
		gc->gc.gc_refs = FROM_GC(gc)->ob_refcnt;
	}
}

接下来就是解决循环引用的关键了:遍历所有节点,对每个节点,使这个节点所引用对象的引用次数减1。

static void
subtract_refs(PyGC_Head *containers) 
{
	traverseproc traverse;
	PyGC_Head *gc = containers->gc.gc_next;
	for (; gc != containers; gc=gc->gc.gc_next) {
		traverse = FROM_GC(gc)->ob_type->tp_traverse;
		(void) traverse(FROM_GC(gc), (visitproc)visit_decref, NULL);
	}
}

每个Python 对象的类型对象都有一个tp_traverse 的函数指针,这个函数实现遍历这个对象所包含的对象,并调用回调函数visit_decref , 将每个对象的gc_refs 减1。

static int
visit_decref(PyObject *op, void *data)
{
	if (PyObject_IS_GC(op)) {
		PyGC_Head *gc = AS_GC(op);
		if (gc->gc.gc_refs > 0)
			gc->gc.gc_refs--;
	}
	return 0;
}

经过subtract_refs 函数后,这“代”上所有的container 对象的gc_refs>=0,并且可以断定那些gc_refs > 0 的对象就一定是 可达的, 也就是不可以被收集的,可以叫做这“代”上的根节点(root node)。

然后,再遍历一遍所有节点,根据节点的引用次数将这些节点分为 可达的不可达的 两类。 Python 初始化一个双向链表unreachable,将在这遍中判断出来的 不可达的 对象链接到该链表上来实现。

static void
move_unreachable(PyGC_Head *young, PyGC_Head *unreachable) 
{
	PyGC_Head *gc = young->gc.gc_next;
	
	while (gc != young) {
		PyGC_Head *next;
		if (gc->gc.gc_refs) {
		     PyObject *op = FROM_GC(gc);
		     traverseproc traverse = op->ob_type->tp_traverse;
		     gc->gc.gc_refs = GC_REACHABLE;
		     (void) traverse(op, (visitproc)visit_reachable, (void *)young);
		     next = gc->gc.gc_next;
		} else {
			next = gc->gc.gc_next;
			gc_list_move(gc, unreachable);
			gc->gc.gc_refs = GC_TENTATIVELY_UNREACHABLE;
		}
		gc = next;
	}
}

可以看到,Python 遍历这“代”上的所有对象,如果gc_refs > 0 就将该对象标记为GC_REACHABLE , 并且调用该对象的类型对象的遍历函数,使用回调函数visit_reachable 进行处理。 如果gc_refs == 0 则将它移到unreachable 中,标记为GC_TENTATIVELY_UNREACHABLE

static int
visit_reachable(PyObject *op, PyGC_Head *reachable) 
{
	if (PyObject_IS_GC(op)) {
		PyGC_Head *gc = AS_GC(op);
		const Py_ssize_t gc_refs = gc->gc.gc_refs;
		
		if (gc_refs == 0) {
		    gc->gc.gc_refs = 1;
		} else if (gc_refs == GC_TENTATIVELY_UNREACHABLE) {
		    gc_list_move(gc, reachable);
		    gc->gc.gc_refs = 1;
		} else {
			assert(gc_refs > 0 || gc_refs == GC_REACHABLE || gc_refs == GC_UNTRACKED);
		}
	}
	return 0;
}

这个代码和我上面写的伪代码的思路有点不一样。Python 的实现中是又使用了一个标记GC_TENTATIVELY_UNREACHABLE, 表示这个对象 暂时(TENTATIVELY)不可达的,之后在遍历 可达的 对象时, 如果 可达的 对象引用了一个标记为GC_TENTATIVELY_UNREACHABLE的对象,则这个对象就变成 可达的 了, 并把它移出 不可达的 链表unreachable

至此,我们就将这“代”上的 垃圾 分离到unreachable 上了,该链表上的对象就是 可收集的(collectable), 接下来就可以在这个双向链表上进行清理工作了。

清除阶段

static void
delete_garbage(PyGC_Head *collectable, PyGC_Head *old)
{
	inquiry clear;
	
	while (!gc_list_is_empty(collectable)) {
		PyGC_Head *gc = collectable->gc.gc_next;
		PyObject *op = FROM_GC(gc);
		
		if ((clear = op->ob_type->tp_clear) != NULL) {
			Py_INCREF(op);  // 将循环引用的对象先加1,防止在调用`dealloc` 的时候导致`ob_refcnt` 变成负数
			clear(op);
			Py_DECREF(op);
		}
		if (collectable->gc.gc_next == gc) {
			/* object is still alive, move it, it may die later */
			/* 所以什么时候会发生这种情况呢??? */
			gc_list_move(gc, old);
			gc->gc.gc_refs = GC_REACHABLE;
		}
	}
}

然后对于每个可回收的对象调用它的类型对象的tp_clear 方法。对于List 如下:

static int
list_clear(PyListObject *a) 
{
	Py_ssize_t i;
	PyObject **item = a->ob_item;
	if (item != NULL) {
		i = a->ob_size;
		a->ob_size = 0;
		a->ob_item = NULL;  // 设为NULL,在`dealloc` 中跳过判定
		a->allocated = 0;
		while (--i >= 0) {
			 Py_XDECREF(item[i]);
		}
		PyMem_FREE(item);
	}
	return 0;
}

将该List 的参数信息都置为零,并且遍历List 中的对象,将它的引用计数ob_refcnt 减1。因为这个是有循环引用的对象, 因此,这里面的某个item[i] 就是循环引用该对象的对象,这个时候将它的引用计数减1后,可能就触发了引用计数的收集机制, 因此,可能就会调用该对象的析构函数(dealloc),List 的如下:

static void
list_dealloc(PyListObject *op) 
{
	Py_ssize_t i;
	PyObject_GC_UnTrack(op);
	Py_TRASHCAN_SAFE_BEGIN(op)
	if (op->ob_item != NULL) {
		i = op->ob_size;
		while (--i >= 0) {
			Py_XDECREF(op->ob_item[i]);
		}
		PyMem_FREE(op->ob_item);
	}
	if (num_free_lists < MAXFREELISTS && PyList_CheckExact(op))
		free_lists[num_free_lists++] = op;
	else
		op->ob_type->tp_free((PyObject *)op);
	Py_TRASHCAN_SAFE_END(op)
}

析构函数的操作和clear 函数的差不多,就多了一个从垃圾回收链表中摘除该元素的操作。 这个 就是之前_PyObject_GC_TRACK 的逆操作。

 #define _PyObject_GC_UNTRACK(o) do { \
     PyGC_Head *g = _Py_AS_GC(o); \
     assert(g->gc.gc_refs != _PyGC_REFS_UNTRACKED); \
     g->gc.gc_refs = _PyGC_REFS_UNTRACKED; \
     g->gc.gc_prev->gc.gc_next = g->gc.gc_next; \
     g->gc.gc_next->gc.gc_prev = g->gc.gc_prev; \
     g->gc.gc_next = NULL; \
     } while (0);

这样,这个container 清空自己用来存储所有引用对象指针的内存,并且将自己放回到空闲链表中。

用上面的例子再说明下,

In [1]: l1 = []
In [2]: l2 = []
In [3]: l1.append(l2)
In [4]: l2.append(l1)

l1 l2 就被链接在collectable 中,假设delete_garbage 中先遍历到l1

-> Py_INCREF(l1)  # l1.ob_refcnts == 2
-> list_clear(l1)
	-> l1.ob_item = NULL
	-> Py_XDECREF(l2)  # l2.ob_refcnts == 0
		-> list_dealloc(l2)
			-> Py_XDECREF(l1)  # l1.ob_refcnts == 1
			-> PyMem_FREE(l2)
			-> add to free_lists
	-> PyMem_FREE(item)
-> Py_DECREF(l1)
	-> list_dealloc(l1)
		-> add to free_lists

需要注意的是,这个时候l1 的引用计数先加了1,否则在调用l2deallocl1 的引用计数就变成0了, 触发l1dealloc 就会使l2 的引用计数变为负数,则报错。

全流程

下面的函数即是垃圾回收的主函数。

static Py_ssize_t
collect(int generation) 
{
	int i;
	PyGC_Head *young;
	PyGC_Head *old;
	PyGC_Head unreachable; /* **不可达的** 对象链表 */
	PyGC_Head *gc;
			
	/* update collection and allocation counters */
	/* 这里的count 记录的并不是在这个“代”的双向链表中链接了多少个对象(这通过gc_list_size得到),
	* 而是这个“代”经历了多少次的collect */
		
	/* (700, 10, 10) 意味着每进行700次的分配就进行一次collect(0) 使得第一“代”的 count+=1,
	* 每进行700*10 次分配就进行一次collect(1)使得第二代的count+=1
	* 每进行700*10*10 次分配进行一次collect(2),将所有代的重新置零 */
	if (generation+1 < NUM_GENERATIONS)
	    generations[generation+1].count += 1;
	for (i = 0; i <= generation; i++)
	    generations[i].count = 0;
		
	/* 将比要收集的“代”要年轻的“代”都合并到这“代”上 */
	for (i = 0; i < generation; i++) {
	    gc_list_merge(GEN_HEAD(i), GEN_HEAD(generation));
	}
		
	young = GEN_HEAD(generation);
	if (generation < NUM_GENERATIONS-1)
	    old = GEN_HEAD(generation+1);
	else
		old = young;
	
	/* 遍历所有节点,对每个节点,使这个节点所引用对象的引用次数减1 */
	update_refs(young);
	subtract_refs(young);
	
	/* 再遍历一遍所有节点,根据节点的引用次数将这些节点分为**可达的** 和**不可达的** 两类 */
	gc_list_init(&unreachable);
	move_unreachable(young, &unreachable);
	
	/* 将这“代”上的对象移到更老的一代上去 */
	if (young != old)
		gc_list_merge(young, old);
	...
	/* 进行清除 */
	delete_garbage(&unreachable, old);
	...
}

至此,终于将Python 的垃圾回收机制码完了。

总结

分代垃圾回收的优点

参考

源码文件:

  • Include/object.h
  • Include/objimpl.h
  • Modules/gcmodule.c

《Python源码剖析》
Garbage Collection for Python
Wilson-GC