Python空闲对象缓存池
Python空闲对象缓存池
参考:Python 源码深度剖析 - fasionchan / 资深 Python 研发工程师.
浮点数加法
要理解Python为什么设计了空闲对象缓存池,首先需要理解一个看起来简单的浮点数运算,在Python里到底是什么样的过程。在Python的源码Objects/floatobject.c
中,定义了Python浮点数对象的加减乘除等运算的函数实现:
1 | /* floatobject.c */ |
在Python中,一切皆对象,浮点数是一个PyFloatObject
类型的结构体。当在Python中对两个浮点数执行加法时,首先定义两个double
类型的局部变量a
、b
。接下来,宏CONVERT_TO_DOUBLE
提取出Python对象转换来的浮点数值,将其赋值给a
、b
。CONVERT_TO_DOUBLE
宏的定义在Objects/floatobject.c
中,PyFloat_AS_DOUBLE
宏的定义在Include/floatobject.h
中:
1 | /* floatobject.c */ |
1 | /* floatobject.h */ |
实际上就是返回ob_fval
字段的值。接下来,PyFPE_START_PROTECT("add", return 0)
和PyFPE_END_PROTECT(a)
之间的代码用于处理浮点异常。这两个宏是用来包裹可能引发浮点异常的代码块的,以确保在发生浮点错误时(比如除以零),程序能够以一种安全的方式处理这种情况。在这个例子中,如果在加法操作过程中发生异常,函数将返回0
。在他们内部,实际执行C层面的浮点数加法运算。最后,将加法运算的结果a
通过PyFloat_FromDouble
函数创建一个新的Python浮点数对象,并返回这个对象。
可以看到,即使进行简单的加法运算,也会涉及到Python对象的创建,对于多步的浮点数运算就更麻烦了。以计算area = pi * r ** 2
为例,在C中执行这个表达式时,会按照下面这样的步骤来执行:
- 计算
r ** 2
:首先计算r
的平方。这一步在CPU中通过乘法指令完成,结果直接存储在寄存器或者栈内存中,不会创建新的对象。 - 计算
pi * (r ** 2)
:然后,用pi
乘以上一步的结果。这一步也直接通过CPU的乘法指令完成,同样,结果直接存储在寄存器或栈内存中。 - 将结果赋值给
area
:最后,将乘法的结果赋值给变量area
。area
变量直接存储了这个计算结果的值。
在C语言中,所有这些操作都是直接对内存和寄存器中的原始数据进行的,并不会涉及到临时对象的创建和销毁过程。因为C语言中的变量直接存储了数据值,而不是像Python中那样的对象引用。在Python中当执行同样的圆面积计算时,计算过程大致如下:
- 计算
r ** 2
:Python首先计算半径r
的平方。这个计算的结果是一个新的浮点数对象,假设它是临时对象t
。 - 计算
pi * t
:接下来,Python计算圆周率pi
与临时对象t
的乘积。这又会产生一个新的浮点数对象,用于存储乘法的结果。这个结果最终被赋值给变量area
。 - 销毁临时对象
t
:在pi * t
的计算完成并且结果被存储之后,临时对象t
就不再需要了。由于Python使用引用计数来管理内存,当t
的引用计数归零时,它被自动销毁。
创建对象时需要分配内存,销毁对象时又需要回收内存。浮点数的计算过程中涉及到了大量临时对象的创建销毁,意味着大量内存分配回收操作,这在性能上是不可接受的,于是Python采用了空闲对象缓存池的办法。
空闲对象缓存池
空闲对象缓存池的思路大致是这样的,程序开始,我们创建了一个浮点数对象,为他分配了内存。等他用完了,我们正常需要销毁它来回收内存。此时,Python先不销毁这个对象,而是先留起来。等到再需要新的浮点数对象的时候,把这个留起来的旧浮点数对象掏出来,改改数值,直接翻新成新的对象。这样,就避免了旧浮点数的销毁以及新浮点数的生成。Python的内存池有着固定的大小,比如100个浮点数对象,在内存和效率之间保持一定的平衡。这个浮点数的空闲对象缓存池,在Python中是通过一个叫作free_list
的链表控制的,如下所示:
1 | /* floatobject.c */ |
free_list
指针指向一个空闲对象链表(缓存池)中的第一个对象,numfree
表示现有空闲对象的数目。在Python解释器刚启动并初始化浮点数对象管理系统时,free_list
指针没有指向任何对象,值为NULL
,numfree=0
,因为还没有任何浮点数对象被回收到空闲链表中。随着程序的运行,我们通过PyFloat_FromDouble
创建了一个浮点数对象,整个函数的运行逻辑如下,
从空闲列表中获取对象:首先直接获取
free_list
指向的PyFloatObject
对象。但由于目前还没有任何浮点数对象被回收,因此free_list
的值为NULL
。更新空闲列表:如果
free_list
不为NULL
(意味着有可用的空闲对象),则将free_list
更新为指向下一个空闲对象,同时空闲列表的计数numfree
减少。这里Python利用了Py_TYPE(op)
作为链接到下一个空闲对象的指针来串成链表。这里需要额外进行一些说明,在
Include/object.h
中定义了三个经常使用的宏:1
2
3
4/* object.h */
Py_TYPE(ob)
就是获取ob
所指向的结构体的类型字段。这里需要复习一下Python对象体系。首先,一个Python对象是一个PyObject
结构体,这个结构体有几个成员变量:比如ob_refcnt
引用计数,用于内存管理;ob_type
指针,指向这个对象对应的类型对象。PyFloatObject
对应的ob_type
字段就是PyFloat_Type
。而PyFloat_Type
显然也是一个PyObject
,它也有ob_type
字段,它的该字段指向类型对象对应的类型对象,类型之类型,PyType_Type
。PyType_Type
也是一个PyObject
,它的ob_type
就指向它自己了。在Python中,这句话对应着1
2type(type) is type
True从
free_list
的初始化可以看到,它是一个PyFloatObject
指针,正常情况下,它指向的ob_type
字段应该指向PyFloat_Type
。但是为了在程序设计简洁的基础上实现空闲对象缓存池,Python把free_list
的ob_type
字段设置为指向下一个空闲的PyFloatObject
对象,而不是PyFloat_Type
,也就是说把这个字段变成了next
指针,来地把空闲对象串成链表。所以在这段代码中,1
2
3
4
5
6
7PyFloatObject *op = free_list;
if (op != NULL) {
free_list = (PyFloatObject *) Py_TYPE(op);
numfree--;
} else {
...
}首先让
op
指向free_list
的第一个空闲对象,如果free_list
不是空的,那么就把free_list
指针更新为下一个空闲PyFloatObject
对象,从而实现链表的更新。分配新对象:如果
free_list
是NULL
(也就是这里发生的),说明没有可用的空闲对象,则通过PyObject_MALLOC
分配一块新的内存来创建一个PyFloatObject
对象。如果内存分配失败(即PyObject_MALLOC
返回NULL),则返回内存错误。初始化对象:无论是重用还是新分配的对象,都需要进行初始化。使用
PyObject_INIT
宏初始化对象,设置其类型为PyFloat_Type
。这个宏相当于在C层面上执行了Python的对象初始化。设置浮点数值:将函数参数
fval
(一个double
类型的值)赋值给op->ob_fval
,这样就设置了新创建的Python浮点数对象的值。返回Python对象:最后,将
op
强制类型转换为PyObject*
并返回。这样,Python代码就可以使用这个新创建的浮点数对象了。
之后,过了一段时间,我们不再需要我们创建的这个浮点数对象了,它的生命周期结束。此时Python将调用float_dealloc
函数,负责处理浮点数对象的回收和释放过程,整个函数的执行流程如下:
- 检查对象类型:通过
PyFloat_CheckExact(op)
检查op
是否确实是一个精确的PyFloatObject
类型。这是为了确保这个释放函数只处理浮点数对象。 - 检查空闲列表容量:如果当前的空闲列表中的对象数量已经达到或超过了最大限制
PyFloat_MAXFREELIST
,那么就不再将该对象加入空闲列表,直接使用PyObject_FREE
宏释放这个对象的内存。 - 加入空闲列表:如果空闲列表还有容量,执行加入空闲对象链表的操作(也就是这里发生的)。首先,增加
numfree
计数器的值,表示空闲列表中对象的数量增加了。然后,通过Py_TYPE(op) = (struct _typeobject *)free_list;
将当前对象的ob_type
字段设置为指向当前的链表头部,最后将free_list
更新为指向当前这个对象,实现链表的数据插入,此时这个对象就被插入到free_list
空闲对象链表中了。 - 处理非精确浮点数类型的释放:如果
op
不是一个精确的浮点数对象(可能是一个浮点数对象的子类),则调用其类型的tp_free
函数来处理释放操作。这是因为子类可能有不同的内存布局或额外的资源需要释放,所以需要调用相应的释放函数。
更进一步理解一下,假如我们继续连续创建并销毁100个浮点数,并且这些操作是依次发生的(每次只有一个浮点数对象被创建然后被销毁,再创建下一个),那么在任何给定时刻,free_list
里最多只会有一个节点。因为每次一个浮点数对象被销毁并加入到free_list
后,下一个被创建的浮点数会立即重用free_list
中的那个节点,在这种情况下free_list
不会累积多个节点。具体来说:
- 当第一个浮点数对象被销毁时,它被加入到空的
free_list
中。 - 然后,当尝试创建一个新的浮点数对象时,解释器会首先检查
free_list
。由于free_list
中有一个可用节点,解释器会重用这个节点来创建新的浮点数对象,而free_list
随即变为空。 - 如果这个新创建的浮点数对象又被销毁了,它会再次被加入到
free_list
中,重复上述过程。
然而,如果这100个浮点数是同时存在的(即在被销毁之前,它们都被创建出来),并且接下来逐一被销毁。在这种情况下,每个被销毁的浮点数对象都可能被加入到free_list
中,直到free_list
达到其最大容量PyFloat_MAXFREELIST
。超过这个容量后,再有浮点数对象被销毁时,它们将不会被加入到free_list
中,而是会直接被内存回收机制回收。这就是Python内存对象缓存池的大致思路。