Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 增强提案

PEP 445 – 为 Python 内存分配器添加新的 API

作者:
Victor Stinner <vstinner at python.org>
BDFL-委托:
Antoine Pitrou <solipsis at pitrou.net>
状态:
最终
类型:
标准跟踪
创建:
2013年6月15日
Python 版本:
3.4
决议:
Python-Dev 消息

目录

摘要

本 PEP 提出新的应用程序编程接口 (API) 来定制 Python 内存分配器。唯一需要符合本 PEP 的实现是 CPython,但其他实现可以选择兼容,或重新使用类似的方案。

基本原理

用例

  • 嵌入 Python 的应用程序,希望将 Python 内存与应用程序的内存隔离,或希望使用针对其 Python 用途优化的不同内存分配器。
  • 在内存有限且 CPU 速度慢的嵌入式设备上运行的 Python。自定义内存分配器可用于提高效率和/或访问设备的所有内存。
  • 内存分配器的调试工具
    • 跟踪内存使用情况(查找内存泄漏)
    • 获取内存分配的位置:Python 文件名和行号,以及内存块的大小
    • 检测缓冲区下溢、缓冲区溢出和 Python 分配器 API 的误用(参见 将内存块分配器的调试检查重新设计为钩子
    • 强制内存分配失败以测试对 MemoryError 异常的处理

提案

新的函数和结构

  • 添加一个新的无 GIL(无需持有 GIL)内存分配器
    • void* PyMem_RawMalloc(size_t size)
    • void* PyMem_RawRealloc(void *ptr, size_t new_size)
    • void PyMem_RawFree(void *ptr)
    • 新分配的内存不会以任何方式初始化。
    • 如果可能,请求零字节会返回一个不同的非 NULL 指针,就像调用了 PyMem_Malloc(1) 一样。
  • 添加新的 PyMemAllocator 结构
    typedef struct {
        /* user context passed as the first argument to the 3 functions */
        void *ctx;
    
        /* allocate a memory block */
        void* (*malloc) (void *ctx, size_t size);
    
        /* allocate or resize a memory block */
        void* (*realloc) (void *ctx, void *ptr, size_t new_size);
    
        /* release a memory block */
        void (*free) (void *ctx, void *ptr);
    } PyMemAllocator;
    
  • 添加新的 PyMemAllocatorDomain 枚举来选择 Python 分配器域。域
    • PYMEM_DOMAIN_RAWPyMem_RawMalloc()PyMem_RawRealloc()PyMem_RawFree()
    • PYMEM_DOMAIN_MEMPyMem_Malloc()PyMem_Realloc()PyMem_Free()
    • PYMEM_DOMAIN_OBJPyObject_Malloc()PyObject_Realloc()PyObject_Free()
  • 添加新的函数来获取和设置内存块分配器
    • void PyMem_GetAllocator(PyMemAllocatorDomain domain, PyMemAllocator *allocator)
    • void PyMem_SetAllocator(PyMemAllocatorDomain domain, PyMemAllocator *allocator)
    • 新的分配器在请求零字节时必须返回一个不同的非 NULL 指针。
    • 对于 PYMEM_DOMAIN_RAW 域,分配器必须是线程安全的:调用分配器时不会持有 GIL。
  • 添加新的 PyObjectArenaAllocator 结构
    typedef struct {
        /* user context passed as the first argument to the 2 functions */
        void *ctx;
    
        /* allocate an arena */
        void* (*alloc) (void *ctx, size_t size);
    
        /* release an arena */
        void (*free) (void *ctx, void *ptr, size_t size);
    } PyObjectArenaAllocator;
    
  • 添加新的函数来获取和设置 pymalloc 使用的 arena 分配器
    • void PyObject_GetArenaAllocator(PyObjectArenaAllocator *allocator)
    • void PyObject_SetArenaAllocator(PyObjectArenaAllocator *allocator)
  • 添加一个新的函数,当使用 PyMem_SetAllocator() 替换内存分配器时,重新安装内存分配器上的调试检查。
    • void PyMem_SetupDebugHooks(void)
    • 在所有内存块分配器上安装调试钩子。该函数可以调用多次,钩子只安装一次。
    • 如果 Python 不是在调试模式下编译的,则该函数不执行任何操作。
  • 如果 size 大于 PY_SSIZE_T_MAX,则内存块分配器始终返回 NULL。检查在调用内部函数之前完成。

注意

pymalloc 分配器针对小于 512 字节且生命周期较短的对象进行了优化。它使用大小为 256 KB 的固定大小内存映射,称为“arena”。

以下是默认情况下分配器是如何设置的

  • PYMEM_DOMAIN_RAWPYMEM_DOMAIN_MEMmalloc()realloc()free();在请求零字节时调用 malloc(1)
  • PYMEM_DOMAIN_OBJpymalloc 分配器,对于大于 512 字节的分配,它回退到 PyMem_Malloc()
  • pymalloc arena 分配器:在 Windows 上使用 VirtualAlloc()VirtualFree(),在可用时使用 mmap()munmap(),或者使用 malloc()free()

将内存块分配器的调试检查重新设计为钩子

从 Python 2.3 开始,Python 在调试模式下对内存分配器执行不同的检查。

  • 新分配的内存填充字节 0xCB,释放的内存填充字节 0xDB
  • 检测 API 违规,例如:对由 PyMem_Malloc() 分配的内存块调用 PyObject_Free()
  • 检测在缓冲区开始之前写入(缓冲区下溢)
  • 检测在缓冲区结束之后写入(缓冲区溢出)

在 Python 3.3 中,通过使用宏替换 PyMem_Malloc()PyMem_Realloc()PyMem_Free()PyObject_Malloc()PyObject_Realloc()PyObject_Free() 来安装检查。新的分配器分配更大的缓冲区并将模式写入以检测缓冲区下溢、缓冲区溢出和释放后使用(通过用字节 0xDB 填充缓冲区)。它使用原始的 PyObject_Malloc() 函数来分配内存。因此,PyMem_Malloc()PyMem_Realloc() 间接调用 PyObject_Malloc()PyObject_Realloc()

本 PEP 将调试检查重新设计为现有分配器上的钩子(在调试模式下)。没有钩子的调用跟踪示例

  • PyMem_RawMalloc() => _PyMem_RawMalloc() => malloc()
  • PyMem_Realloc() => _PyMem_RawRealloc() => realloc()
  • PyObject_Free() => _PyObject_Free()

安装钩子时的调用跟踪(调试模式)

  • PyMem_RawMalloc() => _PyMem_DebugMalloc() => _PyMem_RawMalloc() => malloc()
  • PyMem_Realloc() => _PyMem_DebugRealloc() => _PyMem_RawRealloc() => realloc()
  • PyObject_Free() => _PyMem_DebugFree() => _PyObject_Free()

因此,PyMem_Malloc()PyMem_Realloc() 现在在发布模式和调试模式下都调用 malloc()realloc(),而不是在调试模式下调用 PyObject_Malloc()PyObject_Realloc()

当至少一个内存分配器被 PyMem_SetAllocator() 替换时,必须调用 PyMem_SetupDebugHooks() 函数以在新的分配器之上重新安装调试钩子。

不再直接调用 malloc()

PyObject_Malloc() 如果大小大于或等于 512 字节,则回退到 PyMem_Malloc() 而不是 malloc(),并且 PyObject_Realloc() 回退到 PyMem_Realloc() 而不是 realloc()

直接调用 malloc() 会被替换为 PyMem_Malloc(),或者如果未持有 GIL 则替换为 PyMem_RawMalloc()

像 zlib 或 OpenSSL 这样的外部库可以配置为使用 PyMem_Malloc()PyMem_RawMalloc() 分配内存。如果库的分配器只能全局替换(而不是按对象逐个替换),则在 Python 嵌入到应用程序中时不应替换它。

对于“跟踪内存使用情况”的使用案例,跟踪外部库中分配的内存以获得准确的报告非常重要,因为这些分配可能很大(例如,它们可能会引发 MemoryError 异常),否则会在内存使用情况报告中遗漏。

示例

用例 1:替换内存分配器,保留 pymalloc

每个内存块浪费 2 字节,每个 pymalloc 区域浪费 10 字节的虚拟示例

#include <stdlib.h>

size_t alloc_padding = 2;
size_t arena_padding = 10;

void* my_malloc(void *ctx, size_t size)
{
    int padding = *(int *)ctx;
    return malloc(size + padding);
}

void* my_realloc(void *ctx, void *ptr, size_t new_size)
{
    int padding = *(int *)ctx;
    return realloc(ptr, new_size + padding);
}

void my_free(void *ctx, void *ptr)
{
    free(ptr);
}

void* my_alloc_arena(void *ctx, size_t size)
{
    int padding = *(int *)ctx;
    return malloc(size + padding);
}

void my_free_arena(void *ctx, void *ptr, size_t size)
{
    free(ptr);
}

void setup_custom_allocator(void)
{
    PyMemAllocator alloc;
    PyObjectArenaAllocator arena;

    alloc.ctx = &alloc_padding;
    alloc.malloc = my_malloc;
    alloc.realloc = my_realloc;
    alloc.free = my_free;

    PyMem_SetAllocator(PYMEM_DOMAIN_RAW, &alloc);
    PyMem_SetAllocator(PYMEM_DOMAIN_MEM, &alloc);
    /* leave PYMEM_DOMAIN_OBJ unchanged, use pymalloc */

    arena.ctx = &arena_padding;
    arena.alloc = my_alloc_arena;
    arena.free = my_free_arena;
    PyObject_SetArenaAllocator(&arena);

    PyMem_SetupDebugHooks();
}

用例 2:替换内存分配器,覆盖 pymalloc

如果您有一个专用的分配器,针对小于 512 字节且生命周期较短的对象分配进行了优化,则可以覆盖 pymalloc(替换 PyObject_Malloc())。

每个内存块浪费 2 字节的虚拟示例

#include <stdlib.h>

size_t padding = 2;

void* my_malloc(void *ctx, size_t size)
{
    int padding = *(int *)ctx;
    return malloc(size + padding);
}

void* my_realloc(void *ctx, void *ptr, size_t new_size)
{
    int padding = *(int *)ctx;
    return realloc(ptr, new_size + padding);
}

void my_free(void *ctx, void *ptr)
{
    free(ptr);
}

void setup_custom_allocator(void)
{
    PyMemAllocator alloc;
    alloc.ctx = &padding;
    alloc.malloc = my_malloc;
    alloc.realloc = my_realloc;
    alloc.free = my_free;

    PyMem_SetAllocator(PYMEM_DOMAIN_RAW, &alloc);
    PyMem_SetAllocator(PYMEM_DOMAIN_MEM, &alloc);
    PyMem_SetAllocator(PYMEM_DOMAIN_OBJ, &alloc);

    PyMem_SetupDebugHooks();
}

不需要替换 pymalloc 区域,因为它不再被新的分配器使用。

用例 3:在内存块分配器上设置钩子

设置所有内存块分配器挂钩的示例

struct {
    PyMemAllocator raw;
    PyMemAllocator mem;
    PyMemAllocator obj;
    /* ... */
} hook;

static void* hook_malloc(void *ctx, size_t size)
{
    PyMemAllocator *alloc = (PyMemAllocator *)ctx;
    void *ptr;
    /* ... */
    ptr = alloc->malloc(alloc->ctx, size);
    /* ... */
    return ptr;
}

static void* hook_realloc(void *ctx, void *ptr, size_t new_size)
{
    PyMemAllocator *alloc = (PyMemAllocator *)ctx;
    void *ptr2;
    /* ... */
    ptr2 = alloc->realloc(alloc->ctx, ptr, new_size);
    /* ... */
    return ptr2;
}

static void hook_free(void *ctx, void *ptr)
{
    PyMemAllocator *alloc = (PyMemAllocator *)ctx;
    /* ... */
    alloc->free(alloc->ctx, ptr);
    /* ... */
}

void setup_hooks(void)
{
    PyMemAllocator alloc;
    static int installed = 0;

    if (installed)
        return;
    installed = 1;

    alloc.malloc = hook_malloc;
    alloc.realloc = hook_realloc;
    alloc.free = hook_free;
    PyMem_GetAllocator(PYMEM_DOMAIN_RAW, &hook.raw);
    PyMem_GetAllocator(PYMEM_DOMAIN_MEM, &hook.mem);
    PyMem_GetAllocator(PYMEM_DOMAIN_OBJ, &hook.obj);

    alloc.ctx = &hook.raw;
    PyMem_SetAllocator(PYMEM_DOMAIN_RAW, &alloc);

    alloc.ctx = &hook.mem;
    PyMem_SetAllocator(PYMEM_DOMAIN_MEM, &alloc);

    alloc.ctx = &hook.obj;
    PyMem_SetAllocator(PYMEM_DOMAIN_OBJ, &alloc);
}

注意

PyMem_SetupDebugHooks() 不需要调用,因为内存分配器没有被替换:内存块分配器上的调试检查会在启动时自动安装。

性能

此 PEP(问题 #3329)的实现对 Python 基准测试套件没有明显的开销。

Python 基准测试套件 (-b 2n3) 的结果:一些测试快了 1.04 倍,一些测试慢了 1.04 倍。pybench 微基准测试的结果:全局慢了“+0.1%”(-4.9% 到 +5.6% 之间的差异)。

基准测试的完整输出已附加到问题 #3329 中。

被拒绝的备选方案

获取/设置内存分配器的更具体的函数

最初提出了一组更大的 C API 函数,每个分配器域都有一对函数

  • void PyMem_GetRawAllocator(PyMemAllocator *allocator)
  • void PyMem_GetAllocator(PyMemAllocator *allocator)
  • void PyObject_GetAllocator(PyMemAllocator *allocator)
  • void PyMem_SetRawAllocator(PyMemAllocator *allocator)
  • void PyMem_SetAllocator(PyMemAllocator *allocator)
  • void PyObject_SetAllocator(PyMemAllocator *allocator)

此替代方案被拒绝,因为无法使用更具体的函数编写通用代码:代码必须针对每个内存分配器域进行复制。

使 PyMem_Malloc() 默认重用 PyMem_RawMalloc()

如果 PyMem_Malloc() 默认调用 PyMem_RawMalloc(),则调用 PyMem_SetAllocator(PYMEM_DOMAIN_RAW, alloc) 也会间接修补 PyMem_Malloc()

此替代方案被拒绝,因为 PyMem_SetAllocator() 的行为会根据域的不同而有所不同。始终具有相同的行为会减少错误。

添加新的 PYDEBUGMALLOC 环境变量

有人提议添加一个新的 PYDEBUGMALLOC 环境变量来启用内存块分配器上的调试检查。它将具有与调用 PyMem_SetupDebugHooks() 相同的效果,而无需编写任何 C 代码。另一个优点是允许即使在发布模式下也启用调试检查:调试检查将始终编译在内,但仅在环境变量存在且非空时启用。

此替代方案被拒绝,因为新的环境变量会使 Python 初始化更加复杂。 PEP 432 试图简化 CPython 启动序列。

使用宏来获取可自定义的分配器

为了在默认配置中没有开销,可自定义的分配器将是一个通过配置选项或宏启用的可选功能。

此替代方案被拒绝,因为使用宏意味着必须重新编译扩展模块以使用新的分配器和分配器挂钩。无需重新编译 Python 或扩展模块,这使得调试挂钩在实践中更容易使用。

传递 C 文件名和行号

使用 __FILE____LINE__ 将分配器函数定义为宏,以获取内存分配的 C 文件名和行号。

使用修改后的 PyMemAllocator 结构的 PyMem_Malloc 宏示例

typedef struct {
    /* user context passed as the first argument
       to the 3 functions */
    void *ctx;

    /* allocate a memory block */
    void* (*malloc) (void *ctx, const char *filename, int lineno,
                     size_t size);

    /* allocate or resize a memory block */
    void* (*realloc) (void *ctx, const char *filename, int lineno,
                      void *ptr, size_t new_size);

    /* release a memory block */
    void (*free) (void *ctx, const char *filename, int lineno,
                  void *ptr);
} PyMemAllocator;

void* _PyMem_MallocTrace(const char *filename, int lineno,
                         size_t size);

/* the function is still needed for the Python stable ABI */
void* PyMem_Malloc(size_t size);

#define PyMem_Malloc(size) \
        _PyMem_MallocTrace(__FILE__, __LINE__, size)

GC 分配器函数也必须进行修补。例如,_PyObject_GC_Malloc() 用于许多 C 函数中,因此不同类型的对象将具有相同的分配位置。

此替代方案被拒绝,因为将文件名和行号传递给每个分配器会使 API 更加复杂:将 3 个新参数 (ctx、filename、lineno) 传递给每个分配器函数,而不是只传递一个上下文参数 (ctx)。还必须修改 GC 分配器函数,这会为微不足道的收益增加太多复杂性。

无 GIL 的 PyMem_Malloc()

在 Python 3.3 中,当 Python 在调试模式下编译时,PyMem_Malloc() 间接调用 PyObject_Malloc(),这需要持有 GIL(它不是线程安全的)。这就是为什么必须在持有 GIL 的情况下调用 PyMem_Malloc()

此 PEP 更改了 PyMem_Malloc():它现在始终调用 malloc() 而不是 PyObject_Malloc()。“必须持有 GIL”的限制因此可以从 PyMem_Malloc() 中删除。

此替代方案被拒绝,因为允许在不持有 GIL 的情况下调用 PyMem_Malloc() 会破坏设置了自己的分配器或分配器挂钩的应用程序。持有 GIL 便于开发自定义分配器:无需关心其他线程。它对于调试分配器挂钩也很方便:可以安全地检查 Python 对象,并且可以使用 C API 进行报告。

此外,在内存分配器中调用 PyGILState_Ensure() 会产生意外的行为,尤其是在 Python 启动和创建新的 Python 线程状态时。最好将获取 GIL 的责任从自定义分配器中释放出来。

不添加 PyMem_RawMalloc()

malloc() 替换为 PyMem_Malloc(),但仅当持有 GIL 时。否则,保持 malloc() 不变。

在某些 Python 函数中,PyMem_Malloc() 在未持有 GIL 的情况下使用。例如,Python 的 main()Py_Main() 函数调用 PyMem_Malloc(),而 GIL 尚未存在。在这种情况下,PyMem_Malloc() 将被替换为 malloc()(或 PyMem_RawMalloc())。

此替代方案被拒绝,因为 PyMem_RawMalloc() 对于内存使用情况的准确报告是必需的。当使用调试挂钩跟踪内存使用情况时,无法跟踪通过直接调用 malloc() 分配的内存。PyMem_RawMalloc() 可以挂钩,因此可以跟踪 Python 分配的所有内存,包括在不持有 GIL 的情况下分配的内存。

使用现有的调试工具分析内存使用情况

有许多现有的调试工具可以分析内存使用情况。一些示例:ValgrindPurifyClang AddressSanitizerfailmalloc 等。

问题是检索与内存指针相关的 Python 对象以读取其类型和/或其内容。另一个问题是检索内存分配的来源:C 回溯通常毫无用处(与使用 __FILE____LINE__ 的宏相同的推理,请参见 传递 C 文件名和行号),Python 文件名和行号(甚至 Python 回溯)更有用。

此替代方案被拒绝,因为经典工具无法内省 Python 内部以收集此类信息。能够在持有 GIL 的情况下调用的分配器上设置挂钩,可以从 Python 内部收集大量有用的数据。

添加 msize() 函数

PyMemAllocatorPyObjectArenaAllocator 结构添加另一个函数

size_t msize(void *ptr);

此函数返回内存块或内存映射的大小。如果函数未实现或指针未知(例如:NULL 指针),则返回 (size_t)-1。

在 Windows 上,此函数可以使用 _msize()VirtualQuery() 实现。

该函数可用于实现跟踪内存使用情况的挂钩。分配器的 free() 方法仅获取内存块的地址,而更新内存使用情况需要内存块的大小。

额外的 msize() 函数被拒绝,因为只有少数平台实现了它。例如,使用 GNU libc 的 Linux 没有提供获取内存块大小的函数。msize() 目前未在 Python 源代码中使用。该函数仅用于跟踪内存使用情况,并使 API 更复杂。调试挂钩可以在内部实现该函数,无需将其添加到 PyMemAllocatorPyObjectArenaAllocator 结构中。

没有上下文参数

简化分配器函数的签名,删除上下文参数

  • void* malloc(size_t size)
  • void* realloc(void *ptr, size_t new_size)
  • void free(void *ptr)

分配器挂钩很可能被 PyMem_SetAllocator()PyObject_SetAllocator(),甚至 PyMem_SetRawAllocator() 重用,但挂钩必须根据分配器调用不同的函数。上下文是为不同的 Python 分配器重用相同自定义分配器或挂钩的便捷方式。

在 C++ 中,上下文可用于传递 this

外部库

用于自定义内存分配器的 API 示例。

Python 使用的库

其他库

本 PEP 中新的 ctx 参数的灵感来自于 zlib 和 Oracle 的 OCI 库的 API。

另请参阅 GNU libc:内存分配钩子,它使用不同的方法来挂钩内存分配器。

内存分配器

C 标准库提供了众所周知的 malloc() 函数。其实现取决于平台和 C 库。GNU C 库使用修改后的 ptmalloc2,基于“Doug Lea 的 Malloc”(dlmalloc)。FreeBSD 使用 jemalloc。Google 提供了 tcmalloc,它是 gperftools 的一部分。

malloc() 使用两种内存:堆和内存映射。内存映射通常用于大型分配(例如:大于 256 KB),而堆用于小型分配。

在 UNIX 上,堆由 brk()sbrk() 系统调用处理,并且它是连续的。在 Windows 上,堆由 HeapAlloc() 处理,并且可以是不连续的。内存映射由 UNIX 上的 mmap() 和 Windows 上的 VirtualAlloc() 处理,它们可以是不连续的。

释放内存映射会立即将内存返回给系统。在 UNIX 上,只有当释放的块位于堆的末尾时,堆内存才会返回给系统。否则,只有在释放了释放内存之后的所有内存后,内存才会返回给系统。

为了在堆上分配内存,分配器会尝试重用空闲空间。如果没有足够大的连续空间,则必须扩大堆,即使有比所需大小更多的空闲空间也是如此。此问题称为“内存碎片”:系统看到的内存使用量高于实际使用量。在 Windows 上,如果缺少足够的连续空闲内存,HeapAlloc() 会使用 VirtualAlloc() 创建新的内存映射。

CPython 具有一个用于小于 512 字节的分配的 pymalloc 分配器。此分配器针对具有较短生命周期的小对象进行了优化。它使用称为“区域”的内存映射,其固定大小为 256 KB。

其他分配器

本 PEP 允许根据应用程序的内存使用情况(分配次数、分配大小、对象生命周期等)精确选择要使用的内存分配器。


来源:https://github.com/python/peps/blob/main/peps/pep-0445.rst

上次修改时间:2023-09-09 17:39:29 GMT