cpython和cython的引用计数问题

最近被cython的引用计数折腾了一把,特地把python的各种指针整理一下。

1. 为什么要碰Python.h

自然是为了运行效率,python写起来很爽,但是执行起来却比较慢(相比其他语言而言),这里就要发挥其他强大的多语言胶水功能,特别是内嵌C模块。

常见提升理由来自两个,一个是,用C语言的整数来替代Python里的对象int,Python里一切都是对象,这对于常规类型如int和float来说显然过于笨重;另一个是,用C语言接口来修改内部数据,而通常来说,python的bytes和str是不可修改的,只能创建新的,这提高了稳定和安全,但是牺牲了性能。

举个简单例子,假如需求是要把一堆整数转成字符串,再用冒号分隔拼接起来,直接用python很容易写:

s = [1,2,3,4]
':'.join(str(i) for i in s)

如果整数不多,这当然问题不大,但是如果整数很多的时候,这样做不仅效率差,而且产生的内存碎片也多,如果用C接口来写,自然要复杂很多:

size_t maxlen = 0;
for (int i = 0; i < cnt; ++i) {
    mexlen += arr[i] < 4294967296 ? 10 : 20;
}
PyObject *str = PyBytes_FromStringAndSize(NULL, len);
if (!str) {
    return PyErr_NoMemory();
}
char *buf = PyBytes_AS_STRING(str);
char *out = buf;
for (int i = 0; i < cnt; ++i) {
    out += sprintf(out, "%lld:", arr[i]);
}
if (cnt) {
    --out;
}
_PyBytes_Resize(&str, out - buf);
return str;

上面代码注意如下几点:

1. PyBytes_FromStringAndSize()的第一个参数有值,就是进行拷贝操作,如果像上面为NULL,就相当于创建了一个未初始化的bytes对象

2. _PyBytes_Resize()以下划线打头,就知道这函数比较危险,通常只用于新创建还未被使用的bytes对象

3. return PyErr_NoMemory()相当于PyErr_NoMemory(); return NULL; 即PyErr_NoMemory()为了代码方便,永远返回NULL。从执行时,C代码转入python解释器时,发现返回NULL,解释器就会去查exception.

cpython里的引用计数类别

引用计数就是用来自动化管理内存回收的,如果计数不对,就会导致内存泄漏。引用计数的操作有五个宏:

  1. Py_INCREF 加计数
  2. Py_DECREF 减计数
  3. Py_XDECREF 减计数,如果参数为NULL就什么也不干
  4. Py_XINCREF 加计数,如果参数为NULL就什么也不干
  5. Py_CLEAR 减计数,同时将参数置为NULL。如果参数为NULL就什么了不干

PyList_New()返回一个PyObject*,PyList_GetItem()也是返回一个PyObject*,而PyList_SetItem()需要输入一个PyObject*,这三种情况下,引用计数是如何处理的呢:

PyList_New()返回的是New reference,此时计数为1

PyList_GetItem()返回的是borrowed reference,表示引用计数没有加1,也就是没有所有权,该指针不应该返回或者外传出去,更不能Py_DECREF。

PyList_SetItem()传入的是stolen reference,表示添加时,该item的计数不会加1,相当于转移了所有权,你不应该Py_DECREF操作。

特别注意:PyList_Insert() PyList_Append() PyDict_SetItem(),都不是stolen reference,所以完事后通常需要Py_DECREF操作,而PyList_SetItem()是stolen reference,少有的奇葩。

获取引用计数的办法

打印引用计数:

//C语言
PyObject* d = PyDict_new();
printf("%u\n", d->ob_refcnt);
#Python语言
d = dict()
sys.getrefcount(d)

不过,getrefcount()返回的往往会多1,这是因为在函数传参时,参数列表对传入的每个参数都有一个引用:

>>> d = {}
>>> sys.getrefcount(d)
2

通过ctyps也可以直接读取到对象的引用计数,就不会有多1的问题了:

>>> import ctypes

>>> class PyObject(ctypes.Structure):
>>>     _fields_ = [("refcnt", ctypes.c_long)]
>>>
>>> PyObject.from_address(id(d)).refcnt
>>> 1

同时,函数的栈里也会有引用,例如:

>>> d = {}
>>> def run(d):
>>>     print(sys.getrefcount(d))
>>>

>>> run(d)
4

cython里对接C库时的计数问题

在cython里如果变量指定了空间,就不会有引用了:

cdef class A(base):
    cdef str name
    def __init__(self):
        self.name = "test name"
        print(sys.getrefcount(self.name))

上面的输出就为1,而不是2.

在cython里object转PyObject*是不会有计数变化的,也就是borrowed reference。而PyObject*转object,而会导致计数加1。

对于如下代码,假如函数query_to_dict()返回的是一个owner reference的指针,那么,如果algo.pxd的定义中,query_to_dict()返回的是object,那就没有内存泄漏;而如果返回的是PyObject*,那就有内存泄漏:

cimport algo
res = <dict>algo.query_to_dict(query)

注意:在实现文件中query_to_dict()返回的是PyObject*,而在pxd定义文件里,即可以写返回PyObject*,也可以写返回object,但是两者的引用计数管理不同。

如果pxd中返回为PyObject*时,手动减计数,以避免内存泄漏:

cdef PyObject* ptr = algo.query_to_dict(query)
if ptr != NULL:
    res = <dict>ptr
    Py_DECREF(res)

此问题折腾好久!

引用计数垃圾回收机制的优劣

引用计数的回收机制带来很多问题:首先,它带来额外的内存消耗和性能消耗,然后,为确保一致性,必须要用到thread locking,这让多线程变得难以实现,还有,循环引用问题,处理起来成本高昂。

像dict list tuple class这些容器,他们可以引用他们自己,或者是层层引用,最终形成一个环,这样计数永远不会为0,也就永远不会被回收,形成内存泄漏。python的gc需要每隔一段时间,迭代遍历所有窗口对象来识别哪些出现了循环引用,而但没有外部引用,需要被回收掉了,其性能损失是非常巨大的。为了减少遍历算法的工作量,我们应该尽量少的用到循环引用,尽管它很普遍,这些就用到weakref.ref,也就是引用,但是引用计数不加1的一种危险做法,通常我们在处理parent和children相系时,parent对children为强引用,children对parent使用弱引用。

除了像Python,ObjectC/Swift这样使用引用计数回收机制(counting garbage collector)的,再就是像Java/C#/go那样使用跟踪标记回收机制(tracing garbage collector)的。前者又叫Automatic Reference counting or ARC,后者又叫Tracing garbage collection or TGC。

TGC的逻辑也很简单,就是所有的对象形成一个关系树(如果有循环引用的话,应该叫关系网),可能是多棵树,然后从root开始遍历并标记,有标记的就表示还在使用,没有标记的就表示可以垃圾回收了。如此这般,在变量赋值时就不存在各种计数的消耗了。那TGC是不是就没有缺点呢,显然有。一方面,这种要stop the world而进行的大范围遍历操作,需要周期进行,通常10ms进行一次,这就导致很难设计实时系统,不像ARC那样计数为零就能立马回收,而且像析构函数和原子操作都可能会被打断,程序会有卡顿现象;另一方面,会消耗非常多的内存,间隔时间越长,越流畅,但是内存中的垃圾就更多,而且标记删除,带来的问题是大量的内存碎片,这会导致程序运行久了之后,创建一个大的对象会非常耗时,有优化算法是在内存回收的同时,趁着the world is stopped,将碎片整理到一块,这导致像Java/C#这样的语言完全杜绝指针的概念。如果因为内存不足导致程序被迫进入回收机制,性能会下降非常快,为避免这一问题,有些程序会将内存中的数据swap到磁盘。有实验表明,需要3到4倍的内存,TGC才能运转良好,而如果要像C++那样手动清理内存(即程序员自己管理内存)时一样流畅,则至少需要消耗5倍内存。这就是为什么,iphone顶配手机只有4GB内存,而android随便就是8GB内存,而似乎前者却运行更为流畅。

相比来看,没有一种完美的内存回收机制。

发表于 2020年03月06日 22:30   评论:0   阅读:3206  



回到顶部

首页 | 关于我 | 关于本站 | 站内留言 | rss
python logo   django logo   tornado logo