内存泄漏(英语:memory leak)是计算机科学中的一种资源泄漏,主因是计算机程序的内存管理失当 [1],因而失去对一段已分配内存空间的控制,程序继续占用已不再使用的内存空间,或是存储器所存储之对象无法透过执行代码而访问,令内存资源空耗 [2]。
相信每一个敲过代码的朋友肯定都遇到过内存泄漏,对于 c 语言这种分配了内存却不会自己释放的语言来说,内存泄漏算是家常便饭了,但是我最近在写一个 python 代码的时候同样也遇到了内存泄漏,有朋友可能会问,python 这种自带内存回收机制的语言怎么还会内存泄漏呢?
真相是… 我在 python 中调用了 c 编译出来的动态链接库,然后就内存泄漏了,而且这个泄漏点藏得相当之深,我排查了整整一整天才找到 -_-!
# demo
下面是有内存泄漏的代码的 demo, 这个代码实现的功能很简单,就是在 main.py
中调用 double.dll
的导出函数 double_string
, 实现的功能是传入一个字符串,返回的字符串是传入字符串的两倍,然而内存却在不断的增大
# double.c
//gcc -fPIC -shared double.c -o double.dll | |
#include <stdlib.h> | |
#include <string.h> | |
#include <stdio.h> | |
unsigned char *double_string(unsigned char *input,int len){ | |
unsigned char* double_input = malloc(2*len+1); | |
strcpy(double_input, input); | |
strcat(double_input, input); | |
return double_input; | |
} |
# main.py
# main.py | |
import ctypes | |
dll = ctypes.CDLL('./double.dll') | |
c_double_string = dll.double_string | |
c_double_string.argtypes = [ctypes.POINTER(ctypes.c_uint8), ctypes.c_int] | |
c_double_string.restype = ctypes.POINTER(ctypes.c_uint8) | |
def call_C_func(_data: bytes) -> bytes: | |
data = ctypes.create_string_buffer(_data) | |
data = ctypes.cast(data, ctypes.POINTER(ctypes.c_uint8)) | |
ret = c_double_string(data, len(_data)) | |
_ret = ctypes.string_at(ret, len(_data) * 2) | |
return _ret | |
while True: | |
call_C_func(b"abcd") |
# 内存泄漏点一
这第一个泄漏点很明显,是在 double.c
中,因为在 double_string
中只有一个孤零零的 malloc
, 但是却没有 free
陪着他,所以理所应当的就内存泄漏了
(malloc/realloc/colloc) 和 free 必须两两对应才不会导致内存泄漏,malloc 就是向系统申请一块指定大小的内存,free 则是向系统归还指定地址开始的一块内存
在 c++ 中, new和delete
或者 new[]和delete[]
也必须两两对应,才不会导致内存泄漏
但是这个 free
应该放在什么地方呢?首先肯定是不能在 double_string
中 free 的,不然好不容易把传进来的字符串处理好正准备作为返回值传回去,你反手一个 free 直接把返回值给 free 没了这怎么行呢
所以这个 free 的时机只能是在 main.py
中调用完 c_double_string
之后,python 中该怎么执行 free
函数呀
这该怎么办呢?
让 c 导出一个释放内存的函数 c_free
, 然后在 python 里面再去调用它不就好了
但是这样修改了之后,内存依然在不断的增加
# 内存泄漏点二
内存既然还在泄漏,并且 c 语言里面也没有泄漏点了,那么唯一还有可能的泄漏点就是在 python 中了
在 python 中调用了三个 ctypes
相关的函数,我们一个一个分析来看看有没有可能存在的内存泄漏
create_string_buffer
这个函数的功能是将 bytes
类型转换成 char[]
类型,可以看到, buf
变量的内存是通过 c_char * size
来分配的,由于内存是在 python 中分配,python 会自己把不用的内存回收掉,所以这里不会内存泄漏
string_at
这个函数的功能是从一个地址读取指定长度的字符串,它仅仅只是读数据所以根本就没有能力去导致内存泄漏
cast
嫌疑人 cast
终于出现了,它的功能是将一个数据转换成指定类型的数据
ctypes.cast(obj, type)
此函数类似于 C 的强制转换运算符。 它返回一个 type 的新实例,该实例指向与 obj 相同的内存块。 type 必须为指针类型,而 obj 必须为可以被作为指针来解读的对象。
找找这个函数在 python 中的实现是什么样子的
# Python39\Lib\ctypes\__init__.py | |
def PYFUNCTYPE(restype, *argtypes): | |
class CFunctionType(_CFuncPtr): | |
_argtypes_ = argtypes | |
_restype_ = restype | |
_flags_ = _FUNCFLAG_CDECL | _FUNCFLAG_PYTHONAPI | |
return CFunctionType | |
_cast = PYFUNCTYPE(py_object, c_void_p, py_object, py_object)(_cast_addr) | |
def cast(obj, typ): | |
return _cast(obj, obj, typ) |
然后我们再去追踪 _cast_addr
的声明,发现 cast
是从 _ctypes.pyd
中导出的函数
但是 _ctypes.pyd
毕竟是已经编译过的二进制文件,而我们希望看到的是 cast
的源码,而它在 python 的 github 仓库中可以找到,cast 源码
static PyObject * | |
cast(void *ptr, PyObject *src, PyObject *ctype) | |
{ | |
CDataObject *result; | |
// 确保被转换的类型是一个指针 | |
if (0 == cast_check_pointertype(ctype)) | |
return NULL; | |
result = (CDataObject *)_PyObject_CallNoArg(ctype); | |
if (result == NULL) | |
return NULL; | |
/* | |
The casted objects '_objects' member: | |
It must certainly contain the source objects one. | |
It must contain the source object itself. | |
*/ | |
if (CDataObject_Check(src)) { | |
CDataObject *obj = (CDataObject *)src; | |
CDataObject *container; | |
/* PyCData_GetContainer will initialize src.b_objects, we need | |
this so it can be shared */ | |
container = PyCData_GetContainer(obj); | |
if (container == NULL) | |
goto failed; | |
/* But we need a dictionary! */ | |
if (obj->b_objects == Py_None) { | |
Py_DECREF(Py_None); | |
obj->b_objects = PyDict_New(); | |
if (obj->b_objects == NULL) | |
goto failed; | |
} | |
Py_XINCREF(obj->b_objects); | |
result->b_objects = obj->b_objects; | |
if (result->b_objects && PyDict_CheckExact(result->b_objects)) { | |
PyObject *index; | |
int rc; | |
index = PyLong_FromVoidPtr((void *)src); | |
if (index == NULL) | |
goto failed; | |
rc = PyDict_SetItem(result->b_objects, index, src); | |
Py_DECREF(index); | |
if (rc == -1) | |
goto failed; | |
} | |
} | |
/* Should we assert that result is a pointer type? */ | |
memcpy(result->b_ptr, &ptr, sizeof(void *)); | |
return (PyObject *)result; | |
failed: | |
Py_DECREF(result); | |
return NULL; | |
} |
这里的第 50 行有一个 memcpy
, 作用是将 ptr
的地址赋值给 result->b_ptr
, result->b_ptr
是经过类型转换之后指向转换后内存的指针,而这个变量 ptr
不就是 cast
传递进来的参数!?
我们再来看看 cast
函数在 python 中的声明,这里定义了一个 cast
, 返回的是 _cast
, 而且我们居然发现参数 obj
竟然同时成为了 c
中 cast
的第一个参数 void *ptr
和第二个参数 PyObject *src
, 而且这第一个参数和第二个参数的类型还不一样,分别是 c_void_p
和 py_object
obj
明明是我们准备要类型转换的目标数据,怎么它幻化出了另外一个类型 void *ptr
来作为经过 cast
函数类型转换之后的内存指针呢?
这就不得不说一下 python 中调用外部函数的一个机制了,即强制类型转换
argtypes
当调用外部函数时,每个实际参数都会被传给
argtypes
元组中条目的from_param()
类方法,该方法允许将实际参数适配为此外部函数所接受的对象。 例如,argtypes
元组中的c_char_p
条目将使用 ctypes 转换规则把作为参数传入的字符串转换为字节串对象。
所以说 obj
本身是 py_object
, 但是由于这个机制的存在,被强制转换为了 c_void_p
, 我们不妨打印各个参数的地址来印证一下
这里我们通过 cast 将 py_object
强制转换为了 c_void_p
, 可以发现第一个参数的地址和返回值的地址是一模一样的
在调用 cast 将目标数据转换成目标类型前,python 又分配了一块新的内存来存储经过 cast 转换之后的 data, 然而分配了内存之后却没有即时的释放这块内存,造成内存泄漏也不足为奇了
所以我们现在要做的就是释放掉这一块内存,而这可以使用 ctypes.memset
实现,将这块内存全部都置为 0, 不就相当于把这块内存 free 掉了嘛
再起一个无限循环,通过任务管理器观察 python 进程的内存占用,终于… 不再内存泄漏了