Python整数对象池:“内存泄漏”?

“墙上的斑点”

我第一次注意到短裤上的那个破洞,大概是在金年的三月上旬。如果想要知道具体的时间,那就得回想一下当时我看见的东西。我还能够回忆起,游泳池顶上,摇曳的、白色的灯光不停地映在我的短裤上;有三五名少年一同扎进了水里。哦,那是大概是冬天,因为我回忆起当时的前一天我和室友吃了部队锅,那段时间我没有吸烟,反而嚼了许多口香糖,糖纸总是掉下去,无意中埋下头,这是我第一次看到短裤上的那个破洞。

今天在机场等shuttle时,听到旁边的两个年轻人神采飞扬地讨论游泳的话题。莫名地回想起来,几年前看了一篇讲述Python内部整数对象管理机制的文章,其中谈到了Python应用内存池机制来对“大”整数对象进行管理。从它出发,我想到了一些问题,想要在这里讨论一下。

背景

注:本文讨论的Python版本是Python 2 (2.7.11),C实现。

“一切皆对象”

我们知道,Python的对象,本质上是C中的结构体(生存于在系统堆上)。所有Python对象的根源都是PyObject这个结构体。

打开Python源码目录的Include/,可以找到object.h这一文件,这一个文件,是整个Python对象机制的基础。搜索PyObject,我们将会找到:

typedef struct _object {
    PyObject_HEAD
} PyObject;

再看看PyObject_HEAD这个宏:

#define PyObject_HEAD            \
    _PyObject_HEAD_EXTRA        \
    Py_ssize_t ob_refcnt;        \
    struct _typeobject *ob_type;

在实际编译出的PyObject中,有ob_refcnt这个变量和ob_type这个指针。前者用于Python的引用计数垃圾收集,后者用于指定这个对象的“类型对象”。Python中可以把对象分为“普通”对象和类型对象。也就是说,表示对象的类型,是通过一个指针来指向另一个对象,即类型对象,来实现的。这是“一切皆对象”的一个关键体现。

Python中的整数对象

Python里面,整数对象的头文件intobject.h,也可以在Include/目录里找到,这一文件定义了PyIntObject这一结构体作为Python中的整数对象:

typedef struct {
    PyObject_HEAD
    long ob_ival;
} PyIntObject;

上面提过了,每一个Python对象的ob_type都指向一个类型对象,这里PyIntObject则指向PyInt_Type。想要了解PyInt_Type的相关信息,我们可以打开intobject.c,并找到如下内容:

PyTypeObject PyInt_Type = {
    PyObject_HEAD_INIT(&PyType_Type)
    0,
    "int",
    sizeof(PyIntObject),
    0,
    (destructor)int_dealloc,        /* tp_dealloc */
    (printfunc)int_print,            /* tp_print */
    0,                    /* tp_getattr */
    0,                    /* tp_setattr */
    (cmpfunc)int_compare,            /* tp_compare */
    (reprfunc)int_repr,            /* tp_repr */
    &int_as_number,                /* tp_as_number */
    0,                    /* tp_as_sequence */
    0,                    /* tp_as_mapping */
    (hashfunc)int_hash,            /* tp_hash */
        0,                    /* tp_call */
        (reprfunc)int_repr,            /* tp_str */
    PyObject_GenericGetAttr,        /* tp_getattro */
    0,                    /* tp_setattro */
    0,                    /* tp_as_buffer */
    Py_TPFLAGS_DEFAULT | Py_TPFLAGS_CHECKTYPES |
        Py_TPFLAGS_BASETYPE,        /* tp_flags */
    int_doc,                /* tp_doc */
    0,                    /* tp_traverse */
    0,                    /* tp_clear */
    0,                    /* tp_richcompare */
    0,                    /* tp_weaklistoffset */
    0,                    /* tp_iter */
    0,                    /* tp_iternext */
    int_methods,                /* tp_methods */
    0,                    /* tp_members */
    0,                    /* tp_getset */
    0,                    /* tp_base */
    0,                    /* tp_dict */
    0,                    /* tp_descr_get */
    0,                    /* tp_descr_set */
    0,                    /* tp_dictoffset */
    0,                    /* tp_init */
    0,                    /* tp_alloc */
    int_new,                /* tp_new */
    (freefunc)int_free,                   /* tp_free */
};

这里给Python的整数类型定义了许多的操作。拿int_dealloc,int_freeint_new这几个操作举例。显而易见,int_dealloc负责析构,int_free负责释放该对象所占用的内存,int_new负责创建新的对象。int_as_number也是比较有意思的一个field。它指向一个PyNumberMethods结构体。PyNumberMethods含有许多个函数指针,用以定义对数字的操作,比如加减乘除等等。

通用整数对象池

Python里面,对象的创建一般是通过Python的C API或者是其类型对象。这里就不详述具体的创建机制,具体内容可以参考Python的有关文档。这里我们想要关注的是,整数对象是如何存活在系统内存中的。

整数对象大概会是常见Python程序中使用最频繁的对象了。并且,正如上面提到过的,Python的一切皆对象而且对象都生存在系统的堆上,整数对象当然不例外,那么以整数对象的使用频度,系统堆将面临难以想象的高频的访问。一些简单的循环和计算,都会致使malloc和free一次次被调用,由此带来的开销是难以计数的。此外,heap也会有很多的fragmentation的情况,进一步导致性能下降。

这也是为什么通用整数对象池机制在Python中得到了应用。这里需要说明的是,“小”的整数对象,将全部直接放置于内存中。怎么样定义“小”呢?继续看intobject.c,我们可以看到:

#ifndef NSMALLPOSINTS
#define NSMALLPOSINTS           257
#endif
#ifndef NSMALLNEGINTS
#define NSMALLNEGINTS           5
#endif
#if NSMALLNEGINTS + NSMALLPOSINTS > 0
/* References to small integers are saved in this array so that they
   can be shared.
   The integers that are saved are those in the range
   -NSMALLNEGINTS (inclusive) to NSMALLPOSINTS (not inclusive).
*/
static PyIntObject *small_ints[NSMALLNEGINTS + NSMALLPOSINTS];

值在这个范围内的整数对象将被直接换存在内存中,small_ints负责保存它们的指针。可以理解,这个数组越大,使用整数对象的性能(很可能)就越高。但是这里也是一个trade-off,毕竟系统内存大小有限,直接缓存的小整数数量太多也会影响整体效率。

与小整数相对的是“大”整数对象,也就是除开小整数对象之外的其他整数对象。既然不可能再缓存所有,或者说大部分常用范围的整数,那么一个妥协的办法就是提供一片空间让这些大整数对象按需依次使用。Python也正是这么做的。它维护了两个单向链表block_listfree_list。前者保存了许多被称为PyIntBlock的结构,用于存储被使用的大整数的PyIntObject;后者则用于维护前者所有block之中的空闲内存。

仍旧是在intobject.c之中,我们可以看到:

struct _intblock {
    struct _intblock *next;
    PyIntObject objects[N_INTOBJECTS];
};

typedef struct _intblock PyIntBlock;

一个PyIntBlock保存N_INTOBJECTS个PyIntObject。

现在我们来思考一下一个Python整数对象在内存中的“一生”。

被创建出来之前,先检查其值的大小,如果在小整数的范围内,则直接使用小整数池,只用更新其对应整数对象的引用计数就可以了。如果是大整数,则需要先检查free_list看是否有空闲的空间,要是没有则申请新的内存空间,更新block_listfree_list,否则就使用free_list指向的下一个空闲内存位置并且更新free_list

“内存泄漏”?

So far so good. 上述的机制可以很好减轻fragmentation的问题,同时可以根据所跑的程序不同的特点来做fine tuning从而编译出自己认为合适的Python。但是我们只说了Python整数对象的“来”还没有提它的“去”。当一个整数对象的引用计数变成0以后,会发生什么事情呢?

小整数对象自是不必担心,始终都是在内存中的;大整数对象则需要调用析构操作,int_deallocintobject.c):

static void
int_dealloc(PyIntObject *v)
{
    if (PyInt_CheckExact(v)) {
        Py_TYPE(v) = (struct _typeobject *)free_list;
        free_list = v;
    }
    else
        Py_TYPE(v)->tp_free((PyObject *)v);
}

这个PyInt_CheckExact,来自于intobject.h

#define PyInt_CheckExact(op) ((op)->ob_type == &PyInt_Type)

它起到了类型检查的作用。所以如果这个指针v指向的不是Python原生整数对象,则int_dealloc直接调用该类型的tp_free操作;否则把不再需要的那块内存放入free_list之中。

Py_TYPE的定义:

#ifndef Py_TYPE
    #define Py_TYPE(ob) (((PyObject*)(ob))->ob_type)
#endif

可以看出,free_list所维护的单向链表,是使用ob_type这个field来链接前后元素的。

这也就是说,当一个大整数PyIntObject的生命周期结束时,它之前的内存不会交换给系统堆,而是通过free_list继续被该Python进程占有。

倘若一个程序使用很多的大整数呢?倘若每个大整数只被使用一次呢?是不是很像内存泄漏?

我们来做个简单的计算,假如你的电脑是Macbook Air,8GB Memory,如果你的PyIntObject占用24个Byte,那么满打满算,能够存下大约357913941个整数对象。

下面做个实验。以下程序运行在Macbook Pro (mid 2015), 2.5Ghz i7, 16 GB Memory,Python 2.7.11的环境下:

l = list()
num = 178956971

for i in range(0, num):
    l.append(i)
    if len(l) % 100000 == 0:
        l[:] = []

运行这个程序,会发现它占用了5.44GB的内存:
《Python整数对象池:“内存泄漏”?》

如果把整数个数减半,比如使用89478486,则会占用2.72GB内存(正好原来一半):
《Python整数对象池:“内存泄漏”?》

一个PyIntObject占用多大内存呢?
《Python整数对象池:“内存泄漏”?》

讲道理,24 bytes x 178956971 = 4294967304 bytes,约等于2^32,也就是4GB,那么为什么会占用5.44GB呢?

这并非程序其他部分的overhead,因为,就算你的程序只含有:

for i in range(0, 178956971):
    pass

它仍旧会占用5.44GB内存。5.44 x 2^30 / 178956971大约等于32.64,也就是均摊下来一个整数对象占用了32.64个Byte.

这个问题可以作为一个简单的思考题,这里就不讨论了。

总结

Python的整数对象管理机制并不复杂,但也有趣,刚接触Python的时候是很好的学习材料。细纠下来会发现有很多工程上的考虑以及与之相关的现象,值得我们深入挖掘。

    原文作者:freenik
    原文地址: https://segmentfault.com/a/1190000007617909
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞