Python空闲对象缓存池

Python空闲对象缓存池

参考:Python 源码深度剖析 - fasionchan / 资深 Python 研发工程师.

浮点数加法

要理解Python为什么设计了空闲对象缓存池,首先需要理解一个看起来简单的浮点数运算,在Python里到底是什么样的过程。在Python的源码Objects/floatobject.c中,定义了Python浮点数对象的加减乘除等运算的函数实现:

1
2
3
4
5
6
7
8
9
10
11
12
/* floatobject.c */
static PyObject *
float_add(PyObject *v, PyObject *w)
{
double a,b;
CONVERT_TO_DOUBLE(v, a);
CONVERT_TO_DOUBLE(w, b);
PyFPE_START_PROTECT("add", return 0)
a = a + b;
PyFPE_END_PROTECT(a)
return PyFloat_FromDouble(a);
}

在Python中,一切皆对象,浮点数是一个PyFloatObject类型的结构体。当在Python中对两个浮点数执行加法时,首先定义两个double类型的局部变量ab。接下来,宏CONVERT_TO_DOUBLE提取出Python对象转换来的浮点数值,将其赋值给abCONVERT_TO_DOUBLE宏的定义在Objects/floatobject.c中,PyFloat_AS_DOUBLE宏的定义在Include/floatobject.h中:

1
2
3
4
5
6
/* floatobject.c */
#define CONVERT_TO_DOUBLE(obj, dbl) \
if (PyFloat_Check(obj)) \
dbl = PyFloat_AS_DOUBLE(obj); \
else if (convert_to_double(&(obj), &(dbl)) < 0) \
return obj;
1
2
/* floatobject.h */
#define PyFloat_AS_DOUBLE(op) (((PyFloatObject *)(op))->ob_fval)

实际上就是返回ob_fval字段的值。接下来,PyFPE_START_PROTECT("add", return 0)PyFPE_END_PROTECT(a)之间的代码用于处理浮点异常。这两个宏是用来包裹可能引发浮点异常的代码块的,以确保在发生浮点错误时(比如除以零),程序能够以一种安全的方式处理这种情况。在这个例子中,如果在加法操作过程中发生异常,函数将返回0。在他们内部,实际执行C层面的浮点数加法运算。最后,将加法运算的结果a通过PyFloat_FromDouble函数创建一个新的Python浮点数对象,并返回这个对象。

可以看到,即使进行简单的加法运算,也会涉及到Python对象的创建,对于多步的浮点数运算就更麻烦了。以计算area = pi * r ** 2为例,在C中执行这个表达式时,会按照下面这样的步骤来执行:

  1. 计算r ** 2:首先计算r的平方。这一步在CPU中通过乘法指令完成,结果直接存储在寄存器或者栈内存中,不会创建新的对象。
  2. 计算pi * (r ** 2):然后,用pi乘以上一步的结果。这一步也直接通过CPU的乘法指令完成,同样,结果直接存储在寄存器或栈内存中。
  3. 将结果赋值给area:最后,将乘法的结果赋值给变量areaarea变量直接存储了这个计算结果的值。

在C语言中,所有这些操作都是直接对内存和寄存器中的原始数据进行的,并不会涉及到临时对象的创建和销毁过程。因为C语言中的变量直接存储了数据值,而不是像Python中那样的对象引用。在Python中当执行同样的圆面积计算时,计算过程大致如下:

  1. 计算r ** 2:Python首先计算半径r的平方。这个计算的结果是一个新的浮点数对象,假设它是临时对象t
  2. 计算pi * t:接下来,Python计算圆周率pi与临时对象t的乘积。这又会产生一个新的浮点数对象,用于存储乘法的结果。这个结果最终被赋值给变量area
  3. 销毁临时对象t:在pi * t的计算完成并且结果被存储之后,临时对象t就不再需要了。由于Python使用引用计数来管理内存,当t的引用计数归零时,它被自动销毁。

创建对象时需要分配内存,销毁对象时又需要回收内存。浮点数的计算过程中涉及到了大量临时对象的创建销毁,意味着大量内存分配回收操作,这在性能上是不可接受的,于是Python采用了空闲对象缓存池的办法。

空闲对象缓存池

空闲对象缓存池的思路大致是这样的,程序开始,我们创建了一个浮点数对象,为他分配了内存。等他用完了,我们正常需要销毁它来回收内存。此时,Python先不销毁这个对象,而是先留起来。等到再需要新的浮点数对象的时候,把这个留起来的旧浮点数对象掏出来,改改数值,直接翻新成新的对象。这样,就避免了旧浮点数的销毁以及新浮点数的生成。Python的内存池有着固定的大小,比如100个浮点数对象,在内存和效率之间保持一定的平衡。这个浮点数的空闲对象缓存池,在Python中是通过一个叫作free_list的链表控制的,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/* floatobject.c */

#ifndef PyFloat_MAXFREELIST
#define PyFloat_MAXFREELIST 100
#endif
static int numfree = 0;
static PyFloatObject *free_list = NULL;

PyObject *
PyFloat_FromDouble(double fval)
{
PyFloatObject *op = free_list;
if (op != NULL) {
free_list = (PyFloatObject *) Py_TYPE(op);
numfree--;
} else {
op = (PyFloatObject*) PyObject_MALLOC(sizeof(PyFloatObject));
if (!op)
return PyErr_NoMemory();
}
/* Inline PyObject_New */
(void)PyObject_INIT(op, &PyFloat_Type);
op->ob_fval = fval;
return (PyObject *) op;
}

static void
float_dealloc(PyFloatObject *op)
{
if (PyFloat_CheckExact(op)) {
if (numfree >= PyFloat_MAXFREELIST) {
PyObject_FREE(op);
return;
}
numfree++;
Py_TYPE(op) = (struct _typeobject *)free_list;
free_list = op;
}
else
Py_TYPE(op)->tp_free((PyObject *)op);
}

free_list指针指向一个空闲对象链表(缓存池)中的第一个对象,numfree表示现有空闲对象的数目。在Python解释器刚启动并初始化浮点数对象管理系统时,free_list指针没有指向任何对象,值为NULLnumfree=0,因为还没有任何浮点数对象被回收到空闲链表中。随着程序的运行,我们通过PyFloat_FromDouble创建了一个浮点数对象,整个函数的运行逻辑如下,

  1. 从空闲列表中获取对象:首先直接获取free_list指向的PyFloatObject对象。但由于目前还没有任何浮点数对象被回收,因此free_list的值为NULL

  2. 更新空闲列表:如果free_list不为NULL(意味着有可用的空闲对象),则将free_list更新为指向下一个空闲对象,同时空闲列表的计数numfree减少。这里Python利用了Py_TYPE(op)作为链接到下一个空闲对象的指针来串成链表。

    这里需要额外进行一些说明,在Include/object.h中定义了三个经常使用的宏:

    1
    2
    3
    4
    /* object.h */
    #define Py_REFCNT(ob) (((PyObject*)(ob))->ob_refcnt)
    #define Py_TYPE(ob) (((PyObject*)(ob))->ob_type)
    #define Py_SIZE(ob) (((PyVarObject*)(ob))->ob_size)

    Py_TYPE(ob)就是获取ob所指向的结构体的类型字段。这里需要复习一下Python对象体系。首先,一个Python对象是一个PyObject结构体,这个结构体有几个成员变量:比如ob_refcnt引用计数,用于内存管理;ob_type指针,指向这个对象对应的类型对象。PyFloatObject对应的ob_type字段就是PyFloat_Type。而PyFloat_Type显然也是一个PyObject,它也有ob_type字段,它的该字段指向类型对象对应的类型对象,类型之类型,PyType_TypePyType_Type也是一个PyObject,它的ob_type就指向它自己了。在Python中,这句话对应着

    1
    2
    >>> type(type) is type
    True

    free_list的初始化可以看到,它是一个PyFloatObject指针,正常情况下,它指向的ob_type字段应该指向PyFloat_Type。但是为了在程序设计简洁的基础上实现空闲对象缓存池,Python把free_listob_type字段设置为指向下一个空闲的PyFloatObject对象,而不是PyFloat_Type,也就是说把这个字段变成了next指针,来地把空闲对象串成链表。所以在这段代码中,

    1
    2
    3
    4
    5
    6
    7
    PyFloatObject *op = free_list;
    if (op != NULL) {
    free_list = (PyFloatObject *) Py_TYPE(op);
    numfree--;
    } else {
    ...
    }

    首先让op指向free_list的第一个空闲对象,如果free_list不是空的,那么就把free_list指针更新为下一个空闲PyFloatObject对象,从而实现链表的更新。

  3. 分配新对象:如果free_listNULL(也就是这里发生的),说明没有可用的空闲对象,则通过PyObject_MALLOC分配一块新的内存来创建一个PyFloatObject对象。如果内存分配失败(即PyObject_MALLOC返回NULL),则返回内存错误。

  4. 初始化对象:无论是重用还是新分配的对象,都需要进行初始化。使用PyObject_INIT宏初始化对象,设置其类型为PyFloat_Type。这个宏相当于在C层面上执行了Python的对象初始化。

  5. 设置浮点数值:将函数参数fval(一个double类型的值)赋值给op->ob_fval,这样就设置了新创建的Python浮点数对象的值。

  6. 返回Python对象:最后,将op强制类型转换为PyObject*并返回。这样,Python代码就可以使用这个新创建的浮点数对象了。

之后,过了一段时间,我们不再需要我们创建的这个浮点数对象了,它的生命周期结束。此时Python将调用float_dealloc函数,负责处理浮点数对象的回收和释放过程,整个函数的执行流程如下:

  1. 检查对象类型:通过PyFloat_CheckExact(op)检查op是否确实是一个精确的PyFloatObject类型。这是为了确保这个释放函数只处理浮点数对象。
  2. 检查空闲列表容量:如果当前的空闲列表中的对象数量已经达到或超过了最大限制PyFloat_MAXFREELIST,那么就不再将该对象加入空闲列表,直接使用PyObject_FREE宏释放这个对象的内存。
  3. 加入空闲列表:如果空闲列表还有容量,执行加入空闲对象链表的操作(也就是这里发生的)。首先,增加numfree计数器的值,表示空闲列表中对象的数量增加了。然后,通过Py_TYPE(op) = (struct _typeobject *)free_list;将当前对象的ob_type字段设置为指向当前的链表头部,最后将free_list更新为指向当前这个对象,实现链表的数据插入,此时这个对象就被插入到free_list空闲对象链表中了。
  4. 处理非精确浮点数类型的释放:如果op不是一个精确的浮点数对象(可能是一个浮点数对象的子类),则调用其类型的tp_free函数来处理释放操作。这是因为子类可能有不同的内存布局或额外的资源需要释放,所以需要调用相应的释放函数。

更进一步理解一下,假如我们继续连续创建并销毁100个浮点数,并且这些操作是依次发生的(每次只有一个浮点数对象被创建然后被销毁,再创建下一个),那么在任何给定时刻,free_list里最多只会有一个节点。因为每次一个浮点数对象被销毁并加入到free_list后,下一个被创建的浮点数会立即重用free_list中的那个节点,在这种情况下free_list不会累积多个节点。具体来说:

  1. 当第一个浮点数对象被销毁时,它被加入到空的free_list中。
  2. 然后,当尝试创建一个新的浮点数对象时,解释器会首先检查free_list。由于free_list中有一个可用节点,解释器会重用这个节点来创建新的浮点数对象,而free_list随即变为空。
  3. 如果这个新创建的浮点数对象又被销毁了,它会再次被加入到free_list中,重复上述过程。

然而,如果这100个浮点数是同时存在的(即在被销毁之前,它们都被创建出来),并且接下来逐一被销毁。在这种情况下,每个被销毁的浮点数对象都可能被加入到free_list中,直到free_list达到其最大容量PyFloat_MAXFREELIST。超过这个容量后,再有浮点数对象被销毁时,它们将不会被加入到free_list中,而是会直接被内存回收机制回收。这就是Python内存对象缓存池的大致思路。