详解python垃圾回收

前言

一般面试python的时候谈到垃圾回收机制,我们的回答可能就是简单的:引用计数、标记清除和分代回收。
本文就围绕这三个机制来详细解释python是如何实现并处理不需要的内存对象的。

引用计数

Python垃圾回收主要以引用计数为主,分代回收为辅。

引用计数法的原理是每个对象维护一个ob_refcntpython中一切都是对象(包括对象自己,类型的类型是PyType_Type)所有对象都拥有一些相同内容这些信息都在PyObject中定义,PyObjectpython对象机制的核心。

// object.h
typedef struct _object {
   PyObject_HEAD
} PyObject;

* PyObject_HEAD defines the initial segment of every PyObject. */
#define PyObject_HEAD                   \
    _PyObject_HEAD_EXTRA                \
    Py_ssize_t ob_refcnt;               \
    struct _typeobject *ob_type;

//----------------------------------------------------

  #define _PyObject_HEAD_EXTRA            \
      struct _object *_ob_next;           \
      struct _object *_ob_prev;

// 双向链表结构, 垃圾回收

PyObject定义可以看到python的秘密都在PyObject_HEAD这个宏,所以实际PyObject对象机制核心十分简单就是一个引用计数和一个类型信息的结构体指针。PyObject是每个对象必有的内容,其中ob_refcnt就是做为引用计数。当一个对象有新的引用时,它的ob_refcnt就会增加,当引用它的对象被删除,它的ob_refcnt就会减少。

需要注意的是,类型对象是超越引用计数规则的,类型对象“跳出三界外,不在五行中”永远不会被析构。

//增加计数
#define Py_INCREF(op) (                         \
    _Py_INC_REFTOTAL  _Py_REF_DEBUG_COMMA       \
    ((PyObject*)(op))->ob_refcnt++)

//减少计数
#define _Py_DEC_REFTOTAL        _Py_RefTotal--
#define _Py_REF_DEBUG_COMMA     ,

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

发现ob_refcnt变成0的时候, 该对象生命就结束了会调用_Py_Dealloc,调用各自类型的tp_dealloc

PyAPI_FUNC(void) _Py_Dealloc(PyObject *);
#define _Py_REF_DEBUG_COMMA     ,

#define _Py_Dealloc(op) (                               \
    _Py_INC_TPFREES(op) _Py_COUNT_ALLOCS_COMMA          \
    (*Py_TYPE(op)->tp_dealloc)((PyObject *)(op)))
#endif /* !Py_TRACE_REFS */

Python基本类型的tp_dealloc,通常都会与各自的缓冲池机制相关,释放会优先放入缓冲池中(对应的分配会优先从缓冲池取)。

这个内存分配与回收同缓冲池机制相关。

当无法放入缓冲池时, 会调用各自类型的tp_free来释放自己占用的内存以dict为例子:

PyTypeObject PyDict_Type = {
    PyVarObject_HEAD_INIT(&PyType_Type, 0)
    "dict",
    sizeof(PyDictObject),
    0,
    (destructor)dict_dealloc,                   /* tp_dealloc */
    ....
}


static void
dict_dealloc(register PyDictObject *mp)
{
    .....
    // 如果满足条件, 放入到缓冲池freelist中
    if (numfree < PyDict_MAXFREELIST && Py_TYPE(mp) == &PyDict_Type)
        free_list[numfree++] = mp;
    // 否则, 调用tp_free
    else
        Py_TYPE(mp)->tp_free((PyObject *)mp);
    Py_TRASHCAN_SAFE_END(mp)
}

综上,当计数变为0,触发内存回收动作,python底层调用_Py_Dealloc会调用对应对象类型的tp_dealloc当调用tp_free开始释放内存。

tp_free涉及函数PyObject_DelPyObject_GC_Del,并且自定义类以及容器类型(dictlisttupleset等)使用的都是后者PyObject_GC_Del其他类型使用的是PyObject_Delstr

PyObject_Del 只会释放内存不会调用底层的析构函数,因此自定义的类型和容器都会用PyObject_GC_Del

void
PyObject_GC_Del(void *op)
{
    PyGC_Head *g = AS_GC(op);

    // Returns true if a given object is tracked
    if (IS_TRACKED(op))
        // 从跟踪链表中移除
        gc_list_remove(g);
    if (generations[0].count > 0) {
        generations[0].count--;
    }
    PyObject_FREE(g);
}
  • IS_TRACKED 涉及到标记清除的机制。

  • generations 涉及到了分代回收。

  • PyObject_FREE则和Python底层内存池机制相关。

标记清除

Python中的循环引用总是发生在container对象之间,所谓containser对象即是内部可持有对其他对象的引用,如listclassdictinstance等。

垃圾收集带来的开销依赖于container对象的数量,必需跟踪所创建的每一个container对象,并将这些对象组织到一个集合中。

标记清除(Mark—Sweep)算法是一种基于追踪回收(tracing GC)技术实现的垃圾回收算法。

它分为两个阶段:

第一阶段是标记阶段,GC会把所有的活动对象打上标记

第二阶段是把那些没有标记的对象非活动对象进行回收。

那么GC又是如何判断哪些是活动对象哪些是非活动对象的呢?
对象之间通过引用(指针)连在一起,构成一个有向图,对象构成这个有向图的节点,而引用关系构成这个有向图的边。从根对象(root object)出发,沿着有向边遍历对象,可达的(reachable)对象标(unreachable)记为活动对象,不可达的对象就是要被清除的非活动对象,根对象就是全局变量、调用栈、寄存器。这种简单粗暴的标记清除算法也有明显的缺点:清除非活动的对象前它必须顺序扫描整个堆内存,哪怕只剩下小部分活动对象也要扫描所有对象。

标记清除示例

在上图中,把小黑圈视为全局变量,也就是把它作为root object。

从小黑圈出发,对象1可直达,那么它将被标记。

对象2、3可间接到达也会被标记。

而4和5不可达,那么1、2、3就是活动对象,4和5是非活动对象会被GC回收。

Python使用一个双向链表将这些容器对象组织起来持续追踪活跃的对象,Python的内部C代码将其称为零代(Generation Zero)。

上一节我们已经知道任何一个python对象都含有一个PyObject_HEAD,除此之外另一部分就是对象自身的数据。然而对于一个需要被跟踪的container而言这还不够,因为这个对象必须被链入到双向链表。想要成为一个可收集的对象必须加入额外的信息这个信息位于PyObject_HEAD之前叫做PyGC_Head它已经在上文PyObject_GC_Del函数中出现过。

Modules/gcmodule.c

/* GC information is stored BEFORE the object structure. */
typedef union _gc_head {
    struct {
        // 建立链表需要的前后指针
        union _gc_head *gc_next;
        union _gc_head *gc_prev;
        // 在初始化时会被初始化为 GC_UNTRACED
        Py_ssize_t gc_refs;
    } gc;
    long double dummy;  /* force worst-case alignment */
} PyGC_Head;

对于创建的可收集container对象,其内存分布可以从创建过程窥见一二。

pythoncontainer对象申请内存时,为PyGC_Head也申请了且其位置在container之前。

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_Malloc

#define _PyGC_REFS_UNTRACKED                    (-2)
#define GC_UNTRACKED                    _PyGC_REFS_UNTRACKED

PyObject *
_PyObject_GC_Malloc(size_t basicsize)
{
    PyObject *op;
    PyGC_Head *g;
    if (basicsize > PY_SSIZE_T_MAX - sizeof(PyGC_Head))
        return PyErr_NoMemory();

    // 为 对象本身+PyGC_Head申请内存, 注意分配的size
    g = (PyGC_Head *)PyObject_MALLOC(
        sizeof(PyGC_Head) + basicsize);
    if (g == NULL)
        return PyErr_NoMemory();

    // 初始化 GC_UNTRACED
    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;
}

所以对于PyListObjectPyDictObject等对象内存的分布推车应该如下图所示。

被垃圾回收机制监控的container对象

每次当你创建一个对象或值的时候Python会将其加入零代链表。这种简单粗暴的标记清除算法也有明显的缺点:清除非活动的对象前它必须顺序扫描整个堆内存,哪怕只剩下小部分活动对象也要扫描所有对象。

container实例化的时候,也就是new的时候就会将对象加入链表。以List为例:

PyObject *
PyList_New(Py_ssize_t size)
{
    PyListObject *op;
    op = PyObject_GC_New(PyListObject, &PyList_Type);
    _PyObject_GC_TRACK(op);
    return (PyObject *) op;
}

// =>  _PyObject_GC_TRACK

// objimpl.h
// 加入到可收集对象链表中

#define _PyObject_GC_TRACK(o) do { \
    PyGC_Head *g = _Py_AS_GC(o); \
    if (g->gc.gc_refs != _PyGC_REFS_UNTRACKED) \
        Py_FatalError("GC object already tracked"); \
    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将收集限制在该链表,循环引用一定发生在链表的一群对象之间。在执行_PyObject_GC_TRACK之后我们创建的container对象也就在垃圾回收机制的掌控之中了。同时还有一个从链表移除对象的方法,该方法在对象被销毁的时候调用。

上文我们看到_PyObject_GC_New被执行时调用_PyObject_GC_Malloc分配内存,如果超过阈值会触发GC,在collect_generations中会调用collect其中包含标记清除逻辑。

gcmodule.c

  /* This is the main function.  Read this to understand how the
   * collection process works. */
  static Py_ssize_t
  collect(int generation)
  {
    // 第1步: 将所有比当前代年轻的代中的对象都放到当前代的对象链表中
    /* merge younger generations with one we are currently collecting */
    for (i = 0; i < generation; i++) {
        gc_list_merge(GEN_HEAD(i), GEN_HEAD(generation));
    }


    // 第2步,update_refs
    update_refs(young);
    // 第3步,计算有效引用计数
    subtract_refs(young);
    // 第4步,垃圾标记根据有效引用计数分为不等于0(root对象)和等于0(非root对象, 垃圾, 可回收)
    gc_list_init(&unreachable);
    move_unreachable(young, &unreachable);

    // 第5步,将存活对象放入下一代
      /* Move reachable objects to next generation. */
      if (young != old) {
          if (generation == NUM_GENERATIONS - 2) {
              long_lived_pending += gc_list_size(young);
          }
          gc_list_merge(young, old);
      }
      else {
          /* We only untrack dicts in full collections, to avoid quadratic
             dict build-up. See issue #14775. */
          untrack_dicts(young);
          long_lived_pending = 0;
          long_lived_total = gc_list_size(young);
      }

    // 第6步,执行回收释放内存
      delete_garbage(&unreachable, old);

  }

分代回收

分代机制是一种为了提高垃圾收集的效率以空间换时间的操作方式。从前面标记清除这种垃圾收集机制来看,这种垃圾收集机制所带来的额外操作实际上与系统中总的内存块的数量是相关的,当须要回收的内存块越多时,垃圾检测带来的额外操作就越多,而垃圾回收带来的额外操作就越少;反之,当需回收的内存块越少时,垃圾检测就将比垃圾回收带来更少的额外操作。

将系统中的所有内存块根据其存货的时间划分为不同的集合,每个集合就成为一个, 垃圾收集的频率随着的存活时间的增大而减小(活得越长的对象,就越不可能是垃圾,就应该减少去收集的频率)。存活时间通常是利用经过了几次垃圾回收动作来衡量,如果一个对象经过的回收次数越多,显然其存活时间就越长。

python为了支持分代机制定义了一个结构体gc_generation,对于每一个gc_generation,其内部的count记录了当前这条可收集对象链中一共有多少个对象和一个阈值threshold代表对象链最多可以容纳的对象数量,超过阈值即可触发回收。

gcmodule.c


  struct gc_generation {
      PyGC_Head head;
      int threshold; /* collection threshold */  // 阈值
      int count; /* count of allocations or collections of younger
                    generations */    // 实时个数
  };

Python中,总共三个代。一个代就是一个链表,所有属于同代的内存块都链接在同一个链表中。

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

  //  三代都放到这个数组中
  /* linked lists of container objects */
  static struct gc_generation generations[NUM_GENERATIONS] = {
      /* PyGC_Head,                               threshold,      count */
      {{{GEN_HEAD(0), GEN_HEAD(0), 0}},           700,            0},    //700个container,超过立即触发垃圾回收机制
      {{{GEN_HEAD(1), GEN_HEAD(1), 0}},           10,             0},    // 10个
      {{{GEN_HEAD(2), GEN_HEAD(2), 0}},           10,             0},    // 10个
  };

  PyGC_Head *_PyGC_generation0 = GEN_HEAD(0);

上文_PyObject_GC_TRACK中有一个_PyGC_generation0的变量,这个就是python内部维护的一个指针,指向的正是第0代内存块集合。

0代最多可以容纳700container对象一旦超过就会触发回收机制,这点正是上文_PyObject_GC_Malloc表现出的行为。

虽然是由第0代触发的回收,但是python会借此对所有的代内存链表都进行垃圾回收,当然这只能是在某代的链表count满足越界条件才进行。

 
  static Py_ssize_t
  collect_generations(void)
  {
      int i;
      Py_ssize_t n = 0;

      /* Find the oldest generation (highest numbered) where the count
       * exceeds the threshold.  Objects in the that generation and
       * generations younger than it will be collected. */

      // 从最老的一代, 开始回收
      for (i = NUM_GENERATIONS-1; i >= 0; i--) {  // 遍历所有generation
          if (generations[i].count > generations[i].threshold) {  // 如果超过了阈值
              /* Avoid quadratic performance degradation in number
                 of tracked objects. See comments at the beginning
                 of this file, and issue #4074.
              */
              if (i == NUM_GENERATIONS - 1
                  && long_lived_pending < long_lived_total / 4)
                  continue;
              n = collect(i); // 执行收集
              break;  // notice: break了
          }
      }
      return n;
  }

collect_generations我们可以看到python找到满足越界条件的最老一代进行回收这一代对应的内存和所有年轻代对应的内存,但是源码中找到最老一代并处理后直接就break,这个问题的关键就是collect函数及它接受的参数该函数是垃圾回收的关键实现。

上文collect中,首先将所有比当前代年轻的代中的对象都放到当前代的对象链表中一起处理,并遍历对象链表计算得到有效引用计数。然后通过move_unreachable函数根据引用计数得到计数大于0的和等于0的对象并将它移动到unreachable链表,完成之后一条链表被切分成了2条,在unreachable链表中就是需要回收的目标。如果可能并将当前代中的reachable对象合并到更老的代。

你可能感兴趣的:(详解python垃圾回收)