缓存池我们说浮点数这种对象是经常容易被创建和销毁的,如果每次创建都借助操作系统分配内存、每次销毁都借助操作系统回收内存的话,那效率会低到什么程度,可想而知。 因此Python解释器在操作系统之上封装了一个内存池,在内存管理的时候会详细介绍内存池,目前可以认为内存池就是预先向操作系统申请的一部分内存,专门用于小内存对象的快速创建和销毁,这便是Python的内存池机制。 但浮点数使用的频率很高,我们有时会创建和销毁大量的临时对象,所以如果每一次对象的创建和销毁都伴随着内存相关的操作的话,这个时候即便是有内存池机制,效率也是不高的。 考虑如下代码:
这个语句首先计算半径r的平方,然后根据结果创建一个临时对象,假设是t;然后再将pi和t进行相乘,得到最终结果并赋值给s;最终销毁临时变量t,所以这背后是隐藏着一个临时对象的创建和删除的。 当然这里一行代码可能感觉不到啥,假设我们要计算很多很多个半径对应的面积呢?显然需要写for循环,如果循环一万次就意味着要创建和销毁临时对象各一万次。 因此,如果每一次创建对象都需要分配内存,销毁对象时需要回收内存的话,那么大量临时对象的创建和销毁就意味着也要伴随大量的内存分配以及回收操作,这显然是无法忍受的,更何况Python本身就已经够慢了。 因此Python在浮点数对象被销毁后,并不急着回收对象所占用的内存,换句话说其实对象还在,只是将该对象放入一个空闲的链表中。
后续如果再需要创建新的浮点数对象时,那么从链表中直接取出之前放入的对象(我们认为被回收的对象),然后根据新的浮点数对象重新初始化对应的成员即可,这样就避免了内存分配造成的开销。而这个链表就是我们说的缓存池,当然不光浮点数对象有缓存池,Python中的很多其它对象也有对应的缓存池,比如列表。 而浮点对象的缓存池(链表)同样在 Objects/floatobject.c中定义:
但是问题来了,如果是通过链表来存储的话,那么对象肯定要有一个指针,来指向下一个对象,但是浮点数对象内部似乎没有这样的指针啊。
以上就是浮点数的缓存池,说白了就是一个链表,free_list指向链表的头结点,节点之间通过ob_type充当next指针。 所以PyFloat_FromDouble这个API,我们再来回顾一下:
当op不为NULL时,说明缓存池中有缓存好的对象,于是会将链表的头结点取出来重新分配。但是还要维护free_list,因此要获取下一个节点(PyFloatObject实例),然后让free_list指向它。 在链表中,ob_type被用于指向下一个PyFloatObject,换言之ob_type保存的是下一个PyFloatObject的地址。不过话虽如此,可它的类型仍是struct _typeobject *,或者说PyTypeObject *,因此在存储的时候,下一个PyFloatObject *一定是先转成了PyTypeObject *,之后再交给的ob_type,因为对于指针来说,是可以任意转化的,我们一会在看 float_dealloc 的时候就知道了。 那么同理,这里的Py_TYPE(op)在获取下一个对象的指针之后,还要再转成PyFloatObject *,然后才能交给free_list保存。如果没有下一个对象了,那么free_list就是NULL。在下一次分配的时候,上面的if条件(op!=NULL)就会不成立,从而走下面的else,使用PyObject_MALLOC重新分配内存。 以上就是缓存池在浮点数在的创建过程中起到的作用,也就是对象创建时,会先从缓存池中获取。
这便是Python浮点数缓存池的全部秘密,由于缓存池在提高对象分配效率方面发挥着至关重要的作用,所以Python很多其它的内置实例对象也都实现了缓存池,我们后续在分析的时候会经常看到它的身影。 说白了缓存池的作用只有一个,就是在对象被销毁的时候不释放所占的内存,下次创建新的对象时能够直接拿来用。因为内存没有被释放,因此创建起来就快很多。 看一个思考题:
我们看到两个对象的id是一样的,相信你肯定知道原因。因为a在del之后,对象被放入到缓存池中,然后创建b的时候会从缓存池中获取。所以a指向的对象被重新利用了,内存还是原来的那一块内存,只不过将ob_fval的值从1.414改成了1.732,所以前后地址没有变化。 这就是缓存池,不需要任何内存分配,一个对象就出来了。 修改解释器、验证缓存池最后我们修改一下源码:当对象放入到缓冲池中,我们打印一下放入的浮点数对象的地址;当对象从缓存池中取出时,我们打印一下取出的浮点数对象的地址。 我们第一次创建对象的时候,居然是从缓存池里面获取的,说明在解释器启动之后,链表中就已经有空闲对象了。因为解释器启动时,会做大量的初始化工作。 然后我们使用Python获取它的id,这里转成了16进制,发现地址是一样的。然后放入到缓存池中,放入的对象的地址也是相同的,这和我们得到结论是一致的。 我们看到a指向的对象的地址,和上面变量e指向的对象的地址是一样,说明内存被重新利用了,然后我们再来看看 a、b 之间的关系。 我们创建新的变量a、b并打印地址,然后删除a、b变量,再重新创建a、b变量并打印地址,结果发现它们存储的对象的地址在删除前后正好是相反的。至于原因,如果思考一下将对象放入缓存池、以及从缓存池获取对象的时候所采取的策略,那么很容易就明白了。 因为del a, b的时候会先删除a,再删除b。删除a的时候,会将a指向的对象作为链表中的头结点,然后删除b的时候,会将b指向的对象作为链表中的新的头结点,所以之前a指向的对象就变成了链表中的第二个节点。 而获取的时候,也会从链表的头部开始获取,所以当重新创建变量a的时候,其指向的对象实际上使用的是之前变量b指向的对象所占的内存,而一旦获取,那么free_list指针会向后移动。 因此创建变量b的时候,其指向的对象使用的就是之前变量a指向的对象所占的内存。因此前后打印的地址是相反的,所以我们算是通过实践从另一个角度印证了之前分析的结论。 通过ctypes模拟底层数据结构有时我们想观察底层数据结构的表现行为时,不一定非要修改解释器,因为那样太麻烦,还要重新编译。Python 在上层给我们提供了一种方式,可以让我们通过Python的类轻松地模拟C的结构体。
我们看到id(e)在前后并没有发生改变,证明 e 指向的始终是同一个对象,但是它的值却变了。咦,不是说浮点数是不可变对象吗?如果想变的话只能创建一个新的浮点数,这样一来前后打印的地址应该会变才对啊。 首先说明结论是没错的,可这是从Python的角度而言。如果是从解释器的角度来看的话,没有什么可变不可变,只要我们想让它可变,那么它就是可变的。 为了更好的观察底层数据结构的表现,我们后面会经常使用这种方式,而且会介绍更多的骚操作,但是切记这种动态修改解释器的做法不可用于生产环境。 小结以上就是浮点数的缓存池机制,简单来说是一种空间换时间的做法。 为了避免频繁地和内核打交道,CPython引入了内存池机制,事先会向操作系统申请一部分内存,然后根据大小分成不同的单元,按需分配。这样就无需频繁和操作系统的内核打交道了,因为系统调用是代价昂贵的操作。 但有了内存池还不够,我们知道Python所有的对象都是申请在堆上的,而在堆上分配内存,效率要比栈差很多。所以又引入了缓存池,对象在被销毁后不释放所占内存,而是通过一个链表串起来,留着下次备用。 |
|