Zend 内存管理器

Zend Memory Manager(通常缩写为 ZendMM 或 ZMM)是一个 C 语言层,旨在提供分配和释放动态 请求绑定 内存的能力。

请注意上文中的 “请求绑定”。

ZendMM 不仅仅是 libc 动态内存分配器(主要由 malloc()/free() 这两个 API 调用代表)上的一个经典层。ZendMM 是关于 PHP 在处理请求时必须分配的 请求绑定 内存。

PHP 中的两种主要动态内存池

PHP 是一种无共享架构。好吧,不是 100%。让我们来解释一下。

在继续阅读之前,您可能需要阅读 PHP 生命周期章节,您将获得有关从 PHP 生命周期中得出的不同步骤和周期的其他信息。

PHP 可以在同一个进程中处理数百或数千个请求。默认情况下,当当前请求结束时,PHP 会遗忘它所知道的任何内容。

“遗忘" 意味着释放处理请求时分配的任何动态缓冲区。这意味着在处理请求的过程中,不能使用传统的 libc 调用来分配动态内存。这样做是完全正确的,但却有可能忘记释放这样的缓冲区。

ZendMM 提供了一个 API,通过复制 libc 的 API 来替代 libc 的动态分配器。在处理请求的过程中,程序员必须使用该 API 而不是 libc 的分配器。

例如,当 PHP 处理一个请求时,它会解析 PHP 文件。例如,这些文件将导致函数和类的声明。当编译器编译 PHP 文件时,它会分配一些动态内存来存储发现的类和函数。但是,在请求结束时,PHP 会忘记后者。默认情况下,从一个请求到另一个请求,PHP 会遗忘大量的信息。

不过,也有一些非常罕见的信息需要在多次请求中持续存在。但这并不常见。

什么可以通过请求保持不变?我们称之为 持久对象。让我们再次强调:这只是极少数情况。例如,当前的 PHP 可执行文件路径不会因为不同的请求而改变。后一种信息是永久分配的,这意味着它是通过传统的 libc 的 malloc() 调用分配的。

还有什么?一些字符串。例如,“_SERVER” 字符串会在各个请求之间重复使用,因为每个请求都会创建 $SERVER PHP 数组。因此,SERVER” 字符串本身可以永久分配,因为它只分配过一次。

必须记住的是:

  • 在编写 PHP 核心或扩展时,存在两种动态内存分配:

    • 请求绑定动态分配。

    • 永久动态分配。

  • 请求绑定动态内存分配

    • 只能在 PHP 处理请求时执行(不能在之前或之后)。

    • 只能使用 ZendMM 动态内存分配 API 执行。

    • 在扩展设计中非常常见,基本上 95% 的动态分配都将是请求绑定的。

    • 由 ZendMM 跟踪,并且会通知您有关泄漏的信息。

  • 永久动态内存分配

    • 不应在 PHP 处理请求时执行(不是禁止,但不是一个好主意)。

    • 不由 ZendMM 跟踪,并且不会通知您有关泄漏的信息。

    • 在扩展中应该很少见。

此外,请记住所有 PHP 源代码都是基于这样的内存级别。因此,许多内部结构都是使用 Zend 内存管理器分配的。它们中的大多数都有一个 “持久” API 调用,使用时会导致传统的 libc 分配。

下面是一个请求绑定分配的 zend_string:

zend_string *foo = zend_string_init("foo", strlen("foo"), 0);

以下是持久分配的:

zend_string *foo = zend_string_init("foo", strlen("foo"), 1);

HashTable 也一样。请求绑定分配:

zend_array ar;
zend_hash_init(&ar, 8, NULL, NULL, 0);

持久分配:

zend_array ar;
zend_hash_init(&ar, 8, NULL, NULL, 1);

在所有不同的 Zend API 中都是一样的。通常情况下,将 “0” 作为最后一个参数传递,表示 “我希望使用 ZendMM 分配此结构,因此请求绑定”,或者将 “1” 作为最后一个参数传递,表示 “我希望绕过 ZendMM,使用传统 libc 的 malloc() 调用分配此结构”。

显然,这些结构提供的 API 会记住分配结构的方式,以便在销毁时使用正确的取消分配函数。因此,在这样的代码中:

zend_string_release(foo);
zend_hash_destroy(&ar);

API 知道这些结构是使用请求绑定分配还是永久分配来分配的,并且在第一种情况下将使用 efree() 来释放它,在第二种情况下使用 libc 的 free()

Zend 内存管理器 API

API 位于 Zend/zend_alloc.h 中。

API 调用主要是 C 宏而不是函数,因此如果您调试它们并想了解它们的工作原理,请做好准备。这些调用复制了 libc 的调用,它们通常在函数名称中添加 “e”;因此您不会感到迷茫,而且关于 API 的细节并不多。

基本上,您最常使用的函数是 emalloc(size_t)efree(void *)

还为您提供了 ecalloc(size_t nmemb, size_t size),它分配大小为 sizenmemb,并将区域清零。如果您是一位经验丰富的 C 程序员,您应该知道,只要有可能,最好使用 ecalloc() 而不是 emalloc(),因为 ecalloc() 会将内存区域清零,这对指针错误检测大有帮助。请记住,emalloc() 的工作原理基本上与 libc malloc() 相同:它会在不同的池中寻找足够大的区域,并返回最合适的区域。因此,您可能会得到一个指向垃圾的回收指针。

然后是 safe_emalloc(size_t nmemb, size_t size, size_t offset),它是一个 emalloc(size * nmemb + offset),但它会为您检查溢出。如果您必须提供的数字来自不受信任的来源(如用户空间),则应使用此 API 调用。

关于字符串工具,estrdup(char *)estrndup(char *, size_t len) 允许复制字符串或二进制字符串。

无论发生什么,ZendMM 返回的指针都必须使用 ZendMM(又名 efree() 调用)而不是 libc 的 free() 来释放。

关于持久分配的说明。持久分配会在两次请求之间保持存活。传统上,你可以使用普通的 libc malloc/free 来执行,但 ZendMM 提供了一些 libc allocator 的捷径:“persistent” API。这个 API 以 “p” 开头,让你选择 ZendMM alloc 还是 persistent alloc。因此,pemalloc(size_t, 1) 就是 malloc()pefree(void *, 1) 就是 free()pestrdup(void *, 1) 就是 strdup()

Zend 内存管理器调试屏蔽

ZendMM 提供以下功能:

  • 内存消耗管理。

  • 内存泄漏跟踪和自动释放。

  • 通过预先分配已知大小的缓冲区并在空闲时保持热缓存来加快分配速度

内存消耗管理

ZendMM 是 PHP 用户区 “memory_limit” 功能背后的一层。使用 ZendMM 层分配的每一个字节都会被计算和添加。当达到 INI 的 memory_limit 时,你就知道会发生什么。这也意味着你通过 ZendMM 进行的任何分配都会反映在 PHP 用户态的 memory_get_usage() 调用中。

作为扩展开发人员,这是一件好事,因为它有助于掌握 PHP 进程的堆大小。

如果出现内存限制错误,引擎会从当前代码位置跳出,转到一个捕获块,并顺利终止。但它不会返回到代码中出现限制错误的位置。你必须对此做好准备。

这意味着理论上,ZendMM 不会向你返回一个 NULL 指针。如果操作系统分配失败,或者分配产生了内存限制错误,代码就会进入一个捕获块,而不会返回到你的分配调用。

如果出于某种原因需要绕过这种保护,就必须使用传统的 libc 调用,如 malloc()。不过要小心,知道自己在做什么。如果使用 ZendMM,可能会出现需要分配大量内存的情况,从而导致 PHP 内存限制崩溃。因此,可以使用其他分配器(如 libc),但要注意:您的扩展会增加当前进程堆的大小。在 PHP 中使用 memory_get_usage() 无法看到这一点,但可以通过使用操作系统设施(如 /proc/{pid}/maps)分析当前堆来看到。

如果您需要完全禁用 ZendMM,可以使用 USE_ZEND_ALLOC=0 环境变量启动 PHP。这样,对 ZendMM API 的每次调用(如 emalloc())都将定向到 libc 调用,并且 ZendMM 将被禁用。这在 调试内存 时特别有用。

内存泄露追踪

请记住 ZendMM 的主要规则:当一个请求开始时,ZendMM 就会启动;当你在处理一个请求时需要动态内存,ZendMM 希望你调用它的 API。当前请求结束时,ZendMM 关闭。

关闭时,它会浏览所有活动指针,如果使用的是 调试 的 PHP 版本,它还会就内存泄漏发出警告。

让我们明确一点:如果在当前请求结束时,ZendMM 发现了一些活动内存块,那就意味着这些内存块正在泄漏。在请求结束时,ZendMM 堆中不应该有任何活动的内存块,因为任何分配了内存块的人都应该释放了它们。

如果你忘记释放内存块,它们都会显示在 stderr 中。这种内存泄漏报告过程只在以下情况下有效:

  • 您使用的是 PHP 的调试版本

  • 在 php.ini 中设置了 report_memleaks=On(默认值)

以下是扩展中简单泄漏的一个例子:

PHP_RINIT_FUNCTION(example)
{
    void *foo = emalloc(128);
}

在激活该扩展的调试版本中启动 PHP 时,会在 stderr 上生成:

[Fri Jun 9 16:04:59 2017]  Script:  '/tmp/foobar.php'
/path/to/extension/file.c(123) : Freeing 0x00007fffeee65000 (128 bytes), script=/tmp/foobar.php
=== Total 1 memory leaks detected ===

这些行是在 Zend 内存管理器关闭时生成的,也就是在每个处理过的请求结束时。

但请注意:

  • 显然,ZendMM 对持久分配或以其他方式执行的分配一无所知。因此,ZendMM 只能警告您它知道的分配,每个传统的 libc 分配都不会在此处报告,例如。

  • 如果 PHP 以不正确的方式关闭(我们称之为不干净的关闭),ZendMM 将报告大量泄漏。这是因为当错误关闭时,引擎会使用 longjmp() 调用 catch 块,从而阻止每个清理内存的代码触发。因此,会报告许多泄漏。这种情况尤其发生在调用 PHP 的 exit()/die() 之后,或者在 PHP 的某些关键部分触发致命错误时。

  • 如果你使用 PHP 的非调试版本,stderr 上不会显示任何内容,ZendMM 很愚蠢,但仍会清理任何未由程序员明确释放的分配的请求绑定缓冲区

您必须记住的是,ZendMM 泄漏跟踪是一个很好的附加工具,但它不能替代 真正的 C 内存调试器

生命周期

PHP 将在启动阶段调用 start_memory_manager() 函数,具体来说,是在启动 PHP 进程时(例如,启动 PHP-FPM 服务时,或运行 PHP CLI 脚本时)。这将分配堆和第一个块。

在请求期间,ZendMM 将根据需要分配块。

每次关闭请求时(在 RSHUTDOWN 阶段),Zend Engine 都会调用 shutdown_memory_manager() 函数(该函数调用 zend_mm_shutdown() 函数),并将布尔参数 full 设置为 false。这将为下一个请求进行清理,但不会完全关闭内存管理器。例如,它不会释放堆,而是将当前请求期间使用的平均块数量保留在堆上的 cached_chunks 指针中,以便在下一个请求中重用。

在模块关闭阶段(MSHUTDOWN),Zend Engine 将调用 shutdown_memory_manager() 函数(该函数调用 zend_mm_shutdown() 函数),并将布尔参数 full 设置为 true,这将触发完全关闭并释放所有缓存块以及堆本身。

ZendMM 内部设计

ZendMM 的根是 _zend_mm_heap 结构(如 Zend/zend_alloc.c 中定义),它将在请求初始化期间为每个请求创建并存储在 alloc_globals→mm_heap 中。此堆还附带与其一起分配的第一个块。然后将块细分为页面。较小的分配存储在可能适合一页但有些也跨越多个页面的 bin 中。

内部内存组织

Heap

堆,如 struct _zend_mm_heap 中定义的那样,保存了到块(main_chunkcached_chunks,用于小分配和大分配)、huge_list(用于大分配(>= 2MB))和到 free_slots[BIN] 中的 bin(用于小分配)的链接。初始化后,只有 main_chunk 存在,没有或有一些 cached_chunks

Chunks

每个分块大小为 2 MB,由 512 个页面组成。每个分块的第一页是分块头(chunk header),定义在结构 _zend_mm_chunk(定义在 Zend/zend_alloc.c)中)。数据块以链接列表的形式组织,并带有 prevnext 指针。

每个块在 free_map(512 位)中保存一个比特掩码,其中一个比特表示一个页面是使用中还是空闲的。页面中的信息存储在 map 中,这是一个由 512 个 32 位整数组成的数组。每个整数都用作位图(bitmap),并保存有关该页面的元信息。

Pages

一个页面的大小为 4096 字节,既可以容纳一个 bin(用于小分配),也可以作为大分配的一部分。页面中的内容可以在页面所属的块的映射中找到。

Bins

小的分配会以分区的形式组合在一起。分仓大小是预定义的,有 30 种不同大小(8、16、24、32…​…​3072 字节)。一个 bin 保存相同大小的值,并直接从堆中链接。

一个 bin 可以由多个页面组成。例如 存在一个可容纳 257 字节到 320 字节元素的 bin,它占用 5 个页面,因此可容纳 64 个(由 4096*5/320 得出)相同大小的元素。

分配类别

小额分配

小于或等于 3072 字节的分配会被组织到 bin 中。

如果一个 bin 已经被初始化,zend_mm_heap 结构上的 free_slot 指针就是要使用的地址(这个地址将通过调用 emalloc() 返回,并将递增以指向下一个空闲的 slot,参见 zend_mm_alloc_small 中的实现)。

如果该特定大小的 bin 尚未初始化,则将在 zend_mm_alloc_small_slow 函数中创建,并返回指向 bin 第一个元素的指针。

大额分配

大于 3072 字节、但小到足以放入一个分块(2 MB 分块大小 - 4096 字节分块头(第一页)共 2093056 字节)的分配会直接存储在页面中。第一页将在分块映射中标记为 LRUN,同时也包含分配的页数。

巨额分配

如果分配的内存大于分块大小减去一页(2 MB 分块大小-4096 字节分块头(第一页)等于 2093056 字节),则使用 mmap() 分配内存,并将其放入堆上的 huge_list 链接列表中。

ZendMM 钩子

你可以调用 zend_mm_set_custom_handlers() 函数,并给它指向你的 mallocfreerealloc 处理程序,以及你的自定义堆或通过 zend_mm_get_heap() 获取的当前堆的指针。

void* my_malloc(size_t len) {
    return malloc(len);
}

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

void* my_realloc(void* ptr, size_t len) {
    return realloc(ptr, len);
}

PHP_MINIT_FUNCTION(my_extension) {
    zend_mm_set_custom_handlers(
        zend_mm_get_heap(),
        my_malloc,
        my_free,
        my_realloc
    );
    return SUCCESS;
}

你也可以使用自己的堆,并通过 zend_mm_set_heap()(返回一个指向当前(或旧)堆的指针)注入它。请注意,在带有自定义处理程序的堆上,ZendMM 的行为会有所不同:

  • zend_mm_shutdown()(在 PHP 请求关闭阶段调用)期间,ZendMM 不会进行清理,如果你的自定义处理程序只是转发调用 ZendMM 内部函数,就会造成内存泄漏。

  • zend_mm_gc() 中实现的 ZendMM 垃圾回收器不会做任何事情。这也意味着,如果你在 ZendMM 内部函数的分配过程中达到了内存限制,它也不会尝试释放内存块。

  • 使用自定义处理程序检测堆中是否正在进行完全关闭的唯一方法,就是使用堆的地址调用 free 函数。

  • 你不可能知道 zend_mm_shutdown() 什么时候会执行请求关闭。

常见错误

以下是使用 ZendMM 时最常见的错误以及应该采取的措施。

  1. 当您未处理请求时使用 ZendMM。

    获取有关 PHP 生命周期的信息,以便在扩展中了解何时处理请求,何时不处理。如果您在请求范围之外使用 ZendMM(例如在 MINIT() 中),ZendMM 将在处理第一个请求之前默默清除分配,并且您可能会使用 after-free :根本就不要这样做。

  2. 缓冲区溢出和下溢。

    使用 内存调试器。如果您在 ZendMM 返回的内存区域下方或之后写入,您将覆盖关键的 ZendMM 结构并触发崩溃。如果 ZendMM 能够为您检测到混乱,则可能会显示 “zend_mm_heap 已损坏” 消息。堆栈跟踪将显示从某些代码到某些 ZendMM 代码的崩溃。ZendMM 代码本身不会崩溃。如果您在 ZendMM 代码中间崩溃,这很可能意味着您在某个地方弄乱了指针。启动您最喜欢的内存调试器并查找有问题的部分并修复它。

  3. 混合 API 调用

    如果您分配 ZendMM 指针(例如 emalloc())并使用 libc(free())释放它,或者相反的情况:您将崩溃。要严谨。此外,如果您将 ZendMM 不知道的任何指针传递给 efree():您将崩溃。