揭秘python垃圾回收

说在前面

如果你了解过一些垃圾回收的方案,那么你应该会对垃圾回收的大致流程有些了解。本文适合略懂垃圾回收(GC)的人阅读,这里只是讲python3的垃圾回收算法,不是科普文!

python的GC算法

python使用的是引用计数+标记-清除+分代回收。其中引用计数为主,其他两种为辅。

  1. 引用计数

引用计数仍是目前最有效的GC方案。只需要耗费一点额外的内存来记录一个对象被引用的次数,就能最及时回收该对象。

当一个对象的引用计数为0时,它必定是可回收的(容易理解,自行推理)。python的做法也是这样,只要引用计数为0,则立即回收(del、变量赋值都可能导致某些对象的引用计数减少)。宏Py_DECREF就是用来减少引用计数的,如下:

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

#define Py_DECREF(op)                                   \
    do {                                                \
        PyObject *_py_decref_tmp = (PyObject *)(op);    \
        if (_Py_DEC_REFTOTAL  _Py_REF_DEBUG_COMMA       \
        --(_py_decref_tmp)->ob_refcnt != 0)             \
            _Py_CHECK_REFCNT(_py_decref_tmp)            \
        else                                            \
            _Py_Dealloc(_py_decref_tmp);                \
    } while (0)

从上面可以看出,当一个对象的引用计数ob_refcnt为0时,立即调用_Py_Dealloc释放该对象,这是通过该对象的tp_dealloc方法处理的。下面看看dicttp_dealloc对应的函数:

#define Py_XDECREF(op)                                \
    do {                                              \
        PyObject *_py_xdecref_tmp = (PyObject *)(op); \
        if (_py_xdecref_tmp != NULL)                  \
            Py_DECREF(_py_xdecref_tmp);               \
    } while (0)

static void
dict_dealloc(PyDictObject *mp)
{
    PyObject **values = mp->ma_values;
    PyDictKeysObject *keys = mp->ma_keys;
    Py_ssize_t i, n;

    /* bpo-31095: UnTrack is needed before calling any callbacks */
    PyObject_GC_UnTrack(mp);
    Py_TRASHCAN_SAFE_BEGIN(mp)
    if (values != NULL) {
        if (values != empty_values) {
            for (i = 0, n = mp->ma_keys->dk_nentries; i < n; i++) {
                Py_XDECREF(values[i]);  // 就是这里
            }
            free_values(values);
        }
        DK_DECREF(keys);
    }
    else if (keys != NULL) {
        assert(keys->dk_refcnt == 1);
        DK_DECREF(keys);
    }
    if (numfree < PyDict_MAXFREELIST && Py_TYPE(mp) == &PyDict_Type)
        free_list[numfree++] = mp;
    else
        Py_TYPE(mp)->tp_free((PyObject *)mp);
    Py_TRASHCAN_SAFE_END(mp)
}

可以看出,dict在释放其自身(即mp)之前,将其内所有元素(也就是values,因为key必然不是container)的引用计数减1,这个步骤需要递归处理(当value为container对象时)。

  1. 标记-清除

上面讲的引用计数虽然好用,但是解决不了循环引用的问题。比如下面例子:

a = []      # 新对象,设为q, 引用计数为1
b = []      # 新对象,设为p, 引用计数为1
a.append(b)   # p += 1,即2
b.append(a)   # q += 1,即2
del a    # q -= 1, 即1
del b    # p -= 1, 即1

上面程序执行完,引用计数qp都为1,所以在执行完del后无法立即释放。循环引用只会出现在container之间(如list、dict、class等),其他类型则不会有此问题。于是,需要另外一种手段来辅助解决这个问题,标记-清除算法应运而生。

当一次GC触发时,可能存在哪些对象?

  • 第1种:还未del的对象,即能被程序直接用变量名获取到的
  • 第2种:已被del的对象,但是它还间接被有名变量所引用
  • 第3种:已被del的对象,程序已无法直接、间接访问到它了,但它仍被其他对象引用着(循环引用)

第1种必然是不必进行回收的,但是我们无法直接分辨。第2种也是不必进行回收的,因为程序还能间接取到该对象。第3种就是需要被回收的对象了。

不妨将这些对象看成多个有向图中的结点,举个例子,如下图:

对应程序

c = []
d = [c]
c.append(d)
del c
del d
a = []
b = [a]
del a
  • 变量b对应的对象B可以直接访问到(对应第1种)
  • 变量a相应的对象A还能通过b间接访问到(对应第2种)
  • 变量c、d相应的对象C、D已无法访问到(对应第3种)

标记-清除的思路是分三步进行:

  • 第一步:先找出第1种
    将第2个红框内的所有有向边去掉(不含b->B),得到A、B、C、D分别对应的引用计数是0、1、0、0,其中由于B的引用计数大于0,可断定其可被程序所直接获取,所以B是第1种。

  • 第二步:找出第2种
    从第1种得到的节点集B出发,所有能到达的点的集合就是第2种,即A

  • 第三步:找出第3种并释放它们
    一个对象若不是第1种,也不是第2种,就属于第3种了,所以CD是第3种。是可释放的对象。

至此,标记-清除算法完成。

  1. 分代回收

由于标记-清除算法虽然很实用,但是每次GC都得遍历所有对象,这在对象较多的时候实在吃不消,需要稍微优化一下以减少全员遍历的发生。

有大神观察发现,对象的生存时间越长,则被回收的概率越低。其实根据的是代码规范的局部性原则,即变量要尽量在使用时才定义(这里讲得不太好,请意会一下)。所以python将所有对象被分成3代(称为第012代),其中第0代是最年轻的,即最近才创建的对象,第1代次之,第2代是生存时间最久的。在GC的时候就只需要对其中一代进行精准回收即可,比如说第0代GC绝不会回收第1、2代的对象。

新创建的对象被放在了第0代,当第0代发生GC后,存活下来的对象就被移到第1代,同理,第1代也是如此,而第2代发生GC后存活对象就只能继续放在第2代了。需要留意的是,这3代的GC触发规则还不太一样,当程序中新创建对象个数达到count0后会触发第0代GC,当第0代GC次数达到count1后会触发第1代GC,当第1代GC次数达到count2后会触发第2代GC,执行gc.get_threshold()即可拿到这3个值。

看到这里可能会有个疑问: 分代后如何进行GC?和不分代有什么差别?

首先来看一些细节:

  • python对第0代执行GC时,就只会遍历第0代的对象
  • python对第1代执行GC之前,会将第0代的对象全部移到第1代
  • python对第2代执行GC之前,会将第0、1代的对象全部移到第2代(即全量GC)

不分代的GC是全量的GC,不会出现对象跨代引用的问题。而python对跨代引用的解决方法就是不回收,即涉及到的对象都不回收,它们若是垃圾,最终必定会在同一代被回收。跨代引用的例子如下:

>>> import gc
>>> a = []
>>> b = []
>>> a.append(b)
>>> b.append(a)
>>> gc.collect(0)   # 没有对象被回收,a和b自动升级到第1代
0
>>> c = []
>>> c.append(a)    # 让c引用一下第1代的对象
>>> b.append(c)
>>> 
>>> del a
>>> del b
>>> del c        # 此时c在第0代
>>>              # 此时情况如下图
>>> gc.collect(0)    # 没有对象会被回收
0
>>> gc.collect(1)    # 3个对象都被回收
3

根据上面讲过的所有内容,这样的结果符合预期。在第2次gc.collect(0)时,并没有回收C,因为它和第1代的对象仍有引用关系。其实,这种情况也好处理,python源码如下:

static int
visit_decref(PyObject *op, void *data)
{
    if (PyObject_IS_GC(op)) {
        PyGC_Head *gc = AS_GC(op);
        assert(_PyGCHead_REFS(gc) != 0);
        if (_PyGCHead_REFS(gc) > 0)     
            _PyGCHead_DECREF(gc);       // 引用次数 -1
    }
    return 0;
}

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 = Py_TYPE(FROM_GC(gc))->tp_traverse;
        traverse(FROM_GC(gc), (visitproc)visit_decref, NULL);    // 逐个访问该container引用的所有对象
    }
}

static void
update_refs(PyGC_Head *containers)
{
    PyGC_Head *gc = containers->gc.gc_next;
    for (; gc != containers; gc = gc->gc.gc_next) {                         // 仅处理当前代
        assert(_PyGCHead_REFS(gc) == GC_REACHABLE);
        _PyGCHead_SET_REFS(gc, Py_REFCNT(FROM_GC(gc)));                     // 拷贝
        assert(_PyGCHead_REFS(gc) != 0);    // 等于零的早被立即回收了
    }
}

static void
move_unreachable(PyGC_Head *young, PyGC_Head *unreachable)
{
    PyGC_Head *gc = young->gc.gc_next;
    while (gc != young) {
        PyGC_Head *next;

        if (_PyGCHead_REFS(gc)) {
            PyObject *op = FROM_GC(gc);
            traverseproc traverse = Py_TYPE(op)->tp_traverse;
            assert(_PyGCHead_REFS(gc) > 0);
            _PyGCHead_SET_REFS(gc, GC_REACHABLE);
            traverse(op, (visitproc)visit_reachable, (void *)young);
            next = gc->gc.gc_next;

            if (PyTuple_CheckExact(op)) {
                _PyTuple_MaybeUntrack(op);
            }
        }
        else {
            next = gc->gc.gc_next;
            gc_list_move(gc, unreachable);   // 看这里,只有引用计数为0才可能会被回收
            _PyGCHead_SET_REFS(gc, GC_TENTATIVELY_UNREACHABLE);
        }
        gc = next;
    }
}

static Py_ssize_t
collect(int generation, Py_ssize_t *n_collected, Py_ssize_t *n_uncollectable, int nofail)
{
    ...
    update_refs(young);         // 拷贝引用计数
    subtract_refs(young);       // 去掉有向边
    move_unreachable(young, &unreachable);  // 将可能是垃圾的对象找出来
    ...
}

visit_decref中在执行_PyGCHead_DECREF之前的那个if判断,它可以过滤掉那些比自己年长的对象(指的是年代比当前代大的)。因为update_refs只更新了当前代的对象的_PyGCHead_REFS(gc),其他代的_PyGCHead_REFS(gc)仍然是个特殊值GC_REACHABLE,是个负值,所以其他代的对象的引用计数_PyGCHead_REFS(gc)不会被改变。而源码中只会考虑回收当代中去掉有向边后引用计数为0的对象。

剩下一点小细节:

  • 在执行GC时,对象的引用计数是不能动的,至少对不能回收的对象来说,它还有用呢。所以在执行GC之前需要拷贝一份出来使用,这一步对应上面源码中的update_refs函数。

至此,所有python3垃圾回收的细节都讲完了,若有不明之处,你掏钱,我来给你讲讲。

posted @ 2021-01-07 21:27  xcw0754  阅读(189)  评论(0编辑  收藏  举报