楔子

浮点数这种对象经常容易被创建和销毁,因为它很简单,使用频率高。如果每次创建都借助操作系统分配内存、每次销毁都借助操作系统回收内存的话,那效率会低到什么程度,可想而知。

因此 Python 解释器在操作系统之上封装了一个内存池,在内存管理的时候会详细介绍,目前可以认为内存池就是解释器预先向操作系统申请的一部分内存,专门用于小对象的快速创建和销毁,从而避免了频繁和操作系统打交道,这便是 Python 的内存池机制。

但浮点数的使用频率很高,并且使用时还会创建和销毁大量的临时对象,举个例子:

a = 95.5
b = 117.3
c = 108.9

avg = (a + b + c) / 3

计算平均值的时候,会先计算 a + b,创建一个临时对象。接着让临时对象和 c 相加再创建一个临时对象,然后除以 3得到结果。最后销毁临时对象,并将结果交给变量 avg。

尽管我们平常很少注意到这些,但运算背后所产生的对象的创建和销毁的次数,比我们想象的要多。特别是在循环的时候,会伴随大量的对象创建和销毁操作, 如果每次创建和销毁对象都要伴随着内存操作,这个时候即便有内存池机制,效率也是不高的,因为使用内存池虽然可以不经过操作系统,但它也会增加解释器系统的开销。

因此解释器在浮点数对象被销毁后,并不急着回收对象所占用的内存,换句话说其实对象还在,只是将该对象放入一个空闲的链表中。之前我们说对象可以理解为一片内存空间,对象如果被销毁,那么理论上内存空间要归还给操作系统,或者回到内存池中。但 Python 考虑到效率,并没有真正地释放内存,而是将对象放入到链表中,占用的内存还在。

后续如果需要创建新的浮点数对象时,那么从链表中直接取出之前放入的对象(我们认为被回收的对象),然后根据新的浮点数对象重新初始化对应的字段即可,这样就避免了内存分配造成的开销。而这个链表就是我们说的缓存池,当然不光浮点数对象有缓存池,Python 的很多其它对象也有对应的缓存池,比如列表。

缓存池的实现细节

下面看一下浮点数的缓存池的具体细节。

// Objects/floatobject.c

// 缓存池(链表)的最大长度
// 因此池子里面最多容纳 100 个 PyFloatobject
#define PyFloat_MAXFREELIST    100

// 缓存池已经容纳了多少个 PyFloatobject
static int numfree = 0;
// 指向缓存池(链表)的头节点
static PyFloatObject *free_list = NULL;

我们观察一下 free_list,发现它是一个 PyFloatObect *,这就说明缓存池(链表)中的每一个节点都是 PyFloatObject 实例。但以我们刷 Leetcode 的经验,链表里的每个节点应该有一个 next 字段指向下一个节点,可 PyFloatObject 里面似乎并没有这样的字段啊。

相信你已经猜到原因了,因为解释器是使用 ob_type 字段来指向下一个对象。本来 ob_type 指向的应该是 PyFloat_Type,但在缓存池中指向的是下一个 PyFloatObject。

以上就是浮点数的缓存池,说白了就是一个链表,free_list 指向链表的头节点,节点之间通过 ob_type 字段充当 next 指针。

再探浮点数的创建与销毁

我们以 PyFloat_FromDouble 这个 API 为例,再回顾一下浮点数的创建,

// Objects/floatobject.c

PyObject *
PyFloat_FromDouble(double fval)
{   
    // 获取缓存池的头结点指针
    PyFloatObject *op = free_list;
    // 如果 op 不为 NULL,说明它指向了 PyFloatObject
    // 换句话说就是,缓存池里面有可用节点
    if (op != NULL) {
        // 维护 free_list,让它指向链表的下一个节点
        free_list = (PyFloatObject *) Py_TYPE(op);
        // 缓存池的节点个数减 1
        numfree--;
    } else {
        op = (PyFloatObject*) PyObject_MALLOC(sizeof(PyFloatObject));
        if (!op)
            return PyErr_NoMemory();
    }
    (void)PyObject_INIT(op, &PyFloat_Type);
    op->ob_fval = fval;
    return (PyObject *) op;
}

在链表中,ob_type 被用于指向下一个节点,换言之 ob_type 保存的是下一个 PyFloatObject 的地址。不过话虽如此,可 ob_type 的类型仍是 PyTypeObject *。因此在存储的时候,PyFloatObject * 一定是先转成了 PyTypeObject *,之后再交给 ob_type 保存。因为对于指针来说,是可以任意转化的,我们一会儿看 float_dealloc 的时候就知道了。

那么同理,这里的 Py_TYPE(op) 在获取下一个节点的指针之后,还要再转成 PyFloatObject *,然后才能交给 free_list 保存。如果没有下一个对象了,那么 free_list 就是 NULL,在下一次分配的时候,上面的 if (op!=NULL) 就会不成立,从而走下面的 else,使用 PyObject_MALLOC 重新分配内存。

既然对象创建时可以从缓存池获取,那么销毁的时候,肯定要放入到缓存池中。而销毁对象时,会调用类型对象的 tp_dealloc(析构函数),对于浮点数而言就是 float_dealloc,我们看一下源代码。

static void
float_dealloc(PyFloatObject *op)
{   
    // 如果要放入浮点数的缓存池,那么它必须是浮点数
    if (PyFloat_CheckExact(op)) {
        // 如果缓存池中的节点个数已经达到了 PyFloat_MAXFREELIST,那么直接释放掉
        if (numfree >= PyFloat_MAXFREELIST)  {
            PyObject_FREE(op);
            return;
        }
        // 否则放入到缓存池中
        // 缓存池的节点个数加 1
        numfree++;
        // 因为对象要成为链表的新头结点,那么它的 ob_type(充当 next)要指向当前头结点
        // 所以将 ob_type 的值设置为 state->free_list 即可
        // 但 ob_type 字段的类型为 PyTypeObject *,而 free_list 是 PyFloatobject *
        // 因此赋值之后,要将类型转换一下
        Py_TYPE(op) = (struct _typeobject *)free_list;
        // 然后将 op 赋值为 state->free_list,也就是让 free_list 指向新的头结点
        free_list = op;
    }
    // 如果销毁的对象不是浮点数,而是 float 子类的实例,那么直接释放内存
    else
        Py_TYPE(op)->tp_free((PyObject *)op);
}

以上便是浮点数缓存池的具体实现,说白了缓存池的作用只有一个,就是在对象被销毁的时候不释放所占的内存,下次创建新的对象时能够直接拿来用。因为内存没有被释放,因此创建起来就会快很多。

侵入 PyFloatObject

下面我们修改解释器源代码,当创建和销毁浮点数的时候,打印一些日志信息。

日志信息以字典的形式打印,里面有三个 key,含义如下:

  • action:如果值为 "from_freelist",表示从缓存池获取对象;如果值为 "to_freelist" 表示对象放入缓存池。
  • numfree:从缓存池获取对象、或将对象放入缓存池之后的元素个数。
  • address of object:创建或销毁的对象的地址。

当创建 f1 的时候,指向的浮点数会从缓存池中获取,日志信息中的对象地址显然和 Python 里打印的地址是一样的。然后 del f1,会将对象放入缓存池中。最后新建一个变量 f2,显然它指向的浮点数会复用 f1 指向的浮点数的内存。

再举个例子:

我们重新创建变量 f1、f2,并打印对象地址,然后删除 f1、f2 变量,之后再重新创建 f1、f2 变量并打印对象地址,结果发现地址在删除前后正好是相反的。至于原因,如果思考一下将对象放入缓存池、以及从缓存池获取对象时所采取的策略,那么很容易就明白了。

因为 del f1,f2 的时候会先删除 f1,再删除 f2。删除 f1 的时候,会将 f1 指向的对象作为链表中的头结点,然后删除 f2 的时候,会将 f2 指向的对象作为链表中新的头结点,所以之前 f1 指向的对象就变成了链表中的第二个节点。

浮点数缓存池在添加节点的时候,采用的是头插法。

而获取的时候,也会从链表的头部开始获取,所以当重新创建变量 f1 的时候,其指向的对象使用的是之前变量 f2 指向的对象所占的内存,而一旦获取,那么 free_list 指针会指向下一个节点。然后创建变量 f2 的时候,其指向的对象使用的就是之前变量 f1 指向的对象所占的内存。

因此前后打印的地址是相反的,所以我们算是通过实践从另一个角度印证了之前分析的结论。

通过 ctypes 模拟底层数据结构

有时我们想观察底层数据结构的表现行为时,不一定非要修改解释器,因为那样太麻烦,还要重新编译。Python 在上层提供了一种方式,可以让我们通过 Python 的类轻松地模拟 C 的结构体。

from ctypes import *


class PyObject(Structure):
    """
    继承 ctypes.structure,便可以得到c的结构体
    然后通过 fields 指定结构体字段
    """
    # _fields_ 是一个列表,内部的元组对应结构体的字段
    _fields_ = [
        ("ob_refcnt", c_ssize_t),
        ("ob_type", c_void_p),
    ]
    # ob_refcnt 是 Py_ssize_t 类型,等价于 c_ssize_t
    # 至于 ob_type,我们就指定 void *,因为暂时不用这个字段


class PyFloatObject(PyObject):
    """
    继承 Pyobject,相当于结构体的嵌套
    """
    _fields_ = [
        ("ob_fval", c_double)
    ]


e = 2.71
# 创建 PyFloatObject 实例,返回它的指针
# from_address 表示根据对象的地址创建
f = PyFloatObject.from_address(id(e))
# 此时 e 和 f 都指向了 2.71 这个浮点数

# 注意接下来会发生神奇的一幕
print(e)  # 2.71
print(hex(id(e)))  # 0x1f94d2382f0

# f 等价于底层的 PyFloatObject *,修改 ob_fval 字段
f.ob_fval = 3.14
print(e)  # 3.14
print(hex(id(e)))  # 0x1f94d2382f0

我们看到 id(e) 在前后并没有发生改变,证明 e 指向的始终是同一个对象,但是它的值却变了。咦,不是说浮点数是不可变对象吗?如果想变的话只能创建一个新的浮点数,这样一来前后打印的地址应该会变才对啊。

首先说明结论是没错的,可这是从 Python 的角度而言。如果是从解释器的角度来看的话,没有什么可变不可变,只要我们想让它可变,那么它就是可变的。

另外为了更好地观察底层数据结构的表现,我们后面会经常使用这种方式,而且会介绍更多的骚操作,但是切记这种动态修改解释器的做法不可用于生产环境。

小结

以上就是浮点数的缓存池机制,简单来说是一种空间换时间的做法。

为了避免频繁地和内核打交道,CPython 引入了内存池机制,事先会向操作系统申请一部分内存,然后根据大小划分成不同的单元,按需分配。这样就无需频繁和操作系统的内核打交道了,因为系统调用是代价昂贵的操作。

但有了内存池还不够,我们知道 Python 对象是分配在堆上的,而在堆上分配内存效率要比栈差很多。所以又引入了缓存池,对象在被销毁后不释放所占内存,而是通过一个链表串起来,留着下次备用。


 

欢迎大家关注我的公众号:古明地觉的编程教室。

如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。