内存泄漏(英语:memory leak)是计算机科学中的一种资源泄漏,主因是计算机程序内存管理失当 [1],因而失去对一段已分配内存空间的控制,程序继续占用已不再使用的内存空间,或是存储器所存储之对象无法透过执行代码而访问,令内存资源空耗 [2]

相信每一个敲过代码的朋友肯定都遇到过内存泄漏,对于 c 语言这种分配了内存却不会自己释放的语言来说,内存泄漏算是家常便饭了,但是我最近在写一个 python 代码的时候同样也遇到了内存泄漏,有朋友可能会问,python 这种自带内存回收机制的语言怎么还会内存泄漏呢?

真相是… 我在 python 中调用了 c 编译出来的动态链接库,然后就内存泄漏了,而且这个泄漏点藏得相当之深,我排查了整整一整天才找到 -_-!

# demo

下面是有内存泄漏的代码的 demo, 这个代码实现的功能很简单,就是在 main.py 中调用 double.dll 的导出函数 double_string , 实现的功能是传入一个字符串,返回的字符串是传入字符串的两倍,然而内存却在不断的增大

image-20240302154453894

# 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 里面再去调用它不就好了

image-20240306113800822

但是这样修改了之后,内存依然在不断的增加

image-20240302154600511

# 内存泄漏点二

内存既然还在泄漏,并且 c 语言里面也没有泄漏点了,那么唯一还有可能的泄漏点就是在 python 中了

在 python 中调用了三个 ctypes 相关的函数,我们一个一个分析来看看有没有可能存在的内存泄漏

create_string_buffer

这个函数的功能是将 bytes 类型转换成 char[] 类型,可以看到, buf 变量的内存是通过 c_char * size 来分配的,由于内存是在 python 中分配,python 会自己把不用的内存回收掉,所以这里不会内存泄漏image-20240302155232066

string_at

这个函数的功能是从一个地址读取指定长度的字符串,它仅仅只是读数据所以根本就没有能力去导致内存泄漏

image-20240305225058946

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 中导出的函数

image-20240306115239843

但是 _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 传递进来的参数!?

image-20240306161734881

我们再来看看 cast 函数在 python 中的声明,这里定义了一个 cast , 返回的是 _cast , 而且我们居然发现参数 obj 竟然同时成为了 ccast 的第一个参数 void *ptr 和第二个参数 PyObject *src , 而且这第一个参数和第二个参数的类型还不一样,分别是 c_void_ppy_object

image-20240306162125031

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 , 可以发现第一个参数的地址和返回值的地址是一模一样

image-20240306163659369

在调用 cast 将目标数据转换成目标类型前,python 又分配了一块新的内存来存储经过 cast 转换之后的 data, 然而分配了内存之后却没有即时的释放这块内存,造成内存泄漏也不足为奇了

所以我们现在要做的就是释放掉这一块内存,而这可以使用 ctypes.memset 实现,将这块内存全部都置为 0, 不就相当于把这块内存 free 掉了嘛

image-20240306164150381

再起一个无限循环,通过任务管理器观察 python 进程的内存占用,终于… 不再内存泄漏了

image-20240306164236590

更新于 阅读次数