解密列表的创建与销毁,以及缓存池长什么样子?

文摘   2024-07-24 09:17   北京  

楔子



前面我们分析了列表的底层结构和扩容机制,本篇文章来聊一聊列表的创建和销毁,以及缓存池。



列表的创建



创建列表,解释器只提供了唯一的一个 Python/C API,也就是 PyList_New。这个函数接收一个 size 参数,允许我们在创建 PyListObject 对象时指定底层的 PyObject * 数组的长度。

//Objects/listobject.c
PyObject *
PyList_New(Py_ssize_t size)
{   
    // 声明一个 PyListObject * 变量
    // 指向即将创建的 PyListObject 对象
    PyListObject *op;
    // 底层数组的长度必须大于等于 0
    if (size < 0) {
        PyErr_BadInternalCall();
        return NULL;
    }
    
    // PyList_MAXFREELIST 是一个宏,表示缓存池的容量
    // 编译时可以选择是否禁用缓存池,默认不禁用,容量为 80
#if PyList_MAXFREELIST > 0
    // 获取进程状态对象内部的缓存池
    struct _Py_list_state *state = get_list_state();
    // state->numfree 表示缓存池中已缓存的元素个数
    // 如果大于 0,证明有可用元素,那么会从缓存池中获取
    if (PyList_MAXFREELIST && state->numfree) {
        // 可用元素的数量减一
        state->numfree--;
        // 获取缓存的列表指针,并将指向的列表的引用计数设置为 1
        op = state->free_list[state->numfree];
        OBJECT_STAT_INC(from_freelist);
        _Py_NewReference((PyObject *)op);
    }
    else
#endif
    {
        // 如果缓存池被禁用,或者缓存池中没有可用元素
        // 那么通过 PyObject_GC_New 申请内存
        // 问题来了,之前申请内存不是用的 PyObject_New 吗
        // 这里为啥换成 PyObject_GC_New 呢?我们稍后再说
        op = PyObject_GC_New(PyListObject, &PyList_Type);
        if (op == NULL) {
            return NULL;
        }
    }
    // 如果 size <= 0,那么一定等于 0,此时列表不包含任何元素
    if (size <= 0) {
        // 那么 ob_item 直接设置为 NULL
        op->ob_item = NULL;
    }
    else {
        // 否则为底层数组申请内存,因为存储的都是指针
        // 所以大小为 size * sizeof(PyObject *)
        op->ob_item = (PyObject **) PyMem_Calloc(size, sizeof(PyObject *));
        if (op->ob_item == NULL) {
            Py_DECREF(op);
            return PyErr_NoMemory();
        }
    }
    // 将 ob_size 和 allocated 均设置为 size
    Py_SET_SIZE(op, size);
    op->allocated = size;
    // 让列表被 GC 跟踪
    _PyObject_GC_TRACK(op);
    // 转成泛型指针之后返回
    return (PyObject *) op;
}

整个过程非常好理解,就是先创建一个 PyListObject 对象,然后再为底层数组申请内存,最后通过 ob_item 字段将两者关联起来。当然这个过程中会使用缓存池,关于缓存池一会儿再聊。

然后还要说一下内存申请函数,在这之前我们看到申请内存用的都是 PyObject_New 函数,它和这里的 PyObject_GC_New 有什么区别呢?由于涉及到 Python 的内存管理,我们暂时先不聊那么深,大家先有个基本了解即可,等到介绍内存管理和垃圾回收的时候会详细剖析。

我们知道 Python 对象在底层都是一个结构体,并且结构体内部嵌套了 PyObject。但对于那些能够产生循环引用的可变对象来说,它们除了 PyObject 之外,还包含了一个 PyGC_Head,用于垃圾回收。

所以 PyObject_New 和 PyObject_GC_New 接收的参数是一样的,但后者会多申请 16 字节的内存,这 16 字节是为 PyGC_Head 准备的。那么问题来了,PyGC_Head 在什么地方呢?

PyGC_Head 就在 PyObject 的前面,但是注意:虽然为 PyGC_Head 申请了内存,但返回的是 PyObject 的地址。至于这里面的更多细节,后续在剖析内存管理和垃圾回收的时候细说,目前先简单了解一下即可。

然后再说一下计算内存的两种方式:

import sys

lst = []
# 可以调用 __sizeof__ 方法计算对象的内存
print(lst.__sizeof__())  # 40
# 也可以通过 sys.getsizeof 函数
print(sys.getsizeof(lst))  # 56

我们看到 sys.getsizeof 算出的结果会多出 16 字节,相信你能猜到原因,因为它将 PyGC_Head 也算进去了,而对象的 __sizeof__ 方法则不会算在内。

不过对于字符串、整数、浮点数这种不会产生循环引用的对象来说,由于没有 PyGC_Head,所以两种方式计算的结果是一样的。

import sys

print("".__sizeof__())  # 49
print(sys.getsizeof(""))  # 49

print((123).__sizeof__())  # 28
print(sys.getsizeof(123))  # 28

以上就是列表的创建,整个过程不难理解。



列表的销毁



创建 PyListObject 对象时,会先检测缓存池 free_list 里面是否有可用的对象,有的话直接拿来用,否则通过 malloc 在系统堆上申请。列表的缓存池是使用数组实现的,里面最多维护 80 个 PyListObject 对象。

// Include/internal/pycore_list.h
#define PyList_MAXFREELIST 80

struct _Py_list_state {
#if PyList_MAXFREELIST > 0
    // free_list 是一个 PyListObject * 数组,容量为 80
    // 添加元素时会从数组的尾部添加
    // 获取元素时也会从数组的尾部获取
    PyListObject *free_list[PyList_MAXFREELIST];
    // 缓存池中可用元素数量
    int numfree;
#endif
};

// Objects/listobject.c
static struct _Py_list_state *
get_list_state(void)
{   
    // 列表缓存池会在解释器启动时创建好,并放在进程状态对象中
    PyInterpreterState *interp = _PyInterpreterState_GET();
    // 返回 struct _Py_list_state 实例
    return &interp->list;
}

根据之前的经验我们知道,既然创建的时候能从缓存池中获取,那么在执行析构函数的时候也要把列表放到缓存池里面。看一下列表的析构函数,它由 PyList_Type 的 tp_dealloc 字段负责,而该字段被设置为 list_dealloc。

// Objects/listobject.c
static void
list_dealloc(PyListObject *op)
{
    Py_ssize_t i;
    // 列表可能会产生循环引用,因此创建之后要被 GC 跟踪
    // 而现在要被回收了,所以也要取消 GC 跟踪
    PyObject_GC_UnTrack(op);
    // 这一步的作用,稍后再说
    Py_TRASHCAN_BEGIN(op, list_dealloc)
    // 先释放底层数组
    if (op->ob_item != NULL) {
        // 但是释放之前,还有一件重要的事情
        // 要将底层数组中每个指针指向的对象的引用计数都减去 1
        // 因为它们不再持有对"对象"的引用
        i = Py_SIZE(op);
        while (--i >= 0) {
            Py_XDECREF(op->ob_item[i]);
        }
        // 然后释放底层数组所占的内存
        PyMem_Free(op->ob_item);
    }
#if PyList_MAXFREELIST > 0
    // 获取缓存池
    struct _Py_list_state *state = get_list_state();
    // 如果已缓存的元素小于 80 个,并且 op 指向的是列表
    // 那么将 op 追加到数组中,并将 numfree 自增 1
    if (state->numfree < PyList_MAXFREELIST && PyList_CheckExact(op)) {
        state->free_list[state->numfree++] = op;
        OBJECT_STAT_INC(to_freelist);
    }
    else
#endif
    {
        // 否则将列表的内存释放掉
        Py_TYPE(op)->tp_free((PyObject *)op);
    }
    Py_TRASHCAN_END
}

我们知道创建一个 PyListObject 对象会分为两步,先创建 PyListObject 对象,然后创建底层数组,最后让 PyListObject 对象的 ob_item 字段指向底层数组的首元素。


同理,在销毁一个 PyListObject 对象时,会先释放 ob_item 维护的底层数组,然后在缓存池已满的情况下再释放 PyListObject 对象自身。


现在我们算是明白了缓存池的机制,本来在销毁列表时,要将它的内存释放。但因为缓存池机制,解释器并没有这么做,而是将它的指针放在了缓存池里,至于列表对象则依旧驻留在堆上,只是我们已经无法再访问了。


当以后创建新的 PyListObject 对象时,解释器会首先唤醒这些已经死去的 PyListObject 对象,给它们一个洗心革面、重新做人的机会。但需要注意的是,这里缓存的仅仅是 PyListObject 对象,对于底层数组,其 ob_item 已经不再指向了。


从 list_dealloc 中我们可以看到,PyListObject 对象的指针在放进缓存池之前,ob_item 指向的数组就已经被释放掉了,同时数组中指针指向的对象的引用计数会减 1。所以最终数组中这些指针指向的对象也大难临头各自飞了,或生存、或毁灭,总之此时和 PyListObject 之间已经没有任何联系了。


但是为什么要这么做呢?为什么不连底层数组也一起维护呢?可以想一下,如果继续维护的话,数组中指针指向的对象永远不会被释放,那么很可能会产生悬空指针的问题。


但实际上是可以将底层数组进行保留的,做法是只将数组中指针指向的对象的引用计数减 1,然后将数组中的指针都设置为 NULL,不再指向之前的对象,但并不释放底层数组本身所占用的内存空间。这样一来释放的内存不会交给系统堆,那么再次分配的时候,速度会快很多。但这样会带来两个问题。


1)这些内存没人用也会一直占着,并且只能供 PyListObject 对象的 ob_item 指向的底层数组使用。


2)基于缓存池获取的列表的容量,和新创建的列表的容量不一定匹配。比如底层数组长度为 6 的 PyListObject * 被放入了缓存池,那么表示列表最多容纳 6 个元素,但如果我们要创建一个长度为 8 的列表怎么办?此时依旧要重新为底层数组申请内存。


因此基于以上两个原因,Python 选择将底层数组所占的内存交还给了系统堆,当然也节省了内存。

lst1 = [123]
print(id(lst1))  # 139671899367680
# 扔到缓存池中,放在数组的尾部
del lst1

# 从缓存池中获取,也会从数组的尾部开始拿
lst2 = [123]
print(id(lst2))  # 139671899367680

# 因此打印的地址是一样的

以上就是列表的创建和销毁,以及它的缓存池原理。



trashcan 机制



在看列表的销毁过程时,我们注意到里面有这么一行代码。

这是做什么的呢,首先在 Python 中,我们可以创建具有深度递归的对象,比如:

L = None

for i in range(2 ** 20):
    L = [L]

del L

此时的 L 就是一个嵌套了 2 ** 20 层的列表,当我们删除 L 的时候,会先销毁 L[0]、然后销毁 L[0][0],以此类推,直到递归深度为 2 ** 20

而这样的深度毫无疑问会溢出 C 的调用栈,导致解释器崩溃。但事实上我们在 del L 的时候解释器并没有崩溃,原因就是 CPython 发明了一种名为 trashcan 的机制,它通过延迟销毁的方式来限制销毁的递归深度。关于这一特性,我们知道就好了,不用太关注。

下一篇文章来聊一聊列表的操作在底层是怎么实现的。

古明地觉的编程教室
Python、Rust 程序猿,你感兴趣的内容我都会写,点个关注吧(#^.^#)
 最新文章