编程

PHP 底层原理:Zend 内存管理器

6 2026-10-08 19:05:00

Zend 内存管理器

Zend 内存管理器, 缩写为 ZendMM 或 ZMM ,是一个 C 层,旨在提供分配和释放动态请求绑定内存的能力。

注意上述句子中的“请求绑定(request-bound)”。

ZendMM 不仅仅是 libc 动态内存分配器之上的一个经典层,主要由一对 API 调用 malloc()/free() 表示。ZendMM 涉及的是 PHP 在处理请求时必须分配的与请求相关的内存。

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

PHP 采用无共享架构。当然,也不是 100% 无共享。让我们来解释一下。

注意

在继续阅读本文之前,你可能需要先阅读 PHP 生命周期章节,以便了解关于 PHP 生命周期中不同步骤和周期的更多信息。

PHP 可以将数百或数千个请求处理为同一个进程。默认情况下,当当前请求结束后,PHP 会忘记它所了解的关于该请求的所有信息。

“忘记”某些事情意味着释放处理请求时分配的任何动态缓冲区。这意味着在处理请求的过程中,一定不能使用传统的 libc 调用分配动态内存。这样做是完全合法的,但你可能会忘记释放这样的缓冲区。

ZendMM 附带了一个 API,该 API 通过复制 libc 的动态分配器 API 来替代它。在处理请求的过程中,程序员必须使用该 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”还是“1”,前者表示“我希望这个结构使用 ZendMM 分配,因此是请求绑定的”,后者表示“我希望这个结构绕过 ZendMM,使用传统的 libc 的 malloc() 函数进行分配”。

显然,这些结构提供了一个 API,该 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) 的函数,该函数可分配 nmemb 个单独的大小为 size 的内存块,并清空该区域。如果你是一位经验丰富的 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 分配器提供了一些快捷方式:“持久” 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 进程的堆大小。

如果发生内存限制错误,引擎将从当前代码位置跳转到catch块,并平稳终止。但它不可能返回到代码中内存限制出错的位置。你必须对此做好准备。
这意味着从理论上讲,ZendMM 不会向你返回 NULL 指针。如果操作系统分配失败,或者分配操作产生内存限制错误,代码将进入 catch 块,并且不会返回到你的分配调用。

如果出于任何原因你需要绕过这种保护,那么必须使用传统的 libc 调用,如 malloc()。但请务必小心,并了解自己在做什么。在使用 ZendMM 时,可能会出现需要分配大量内存的情况,这可能会导致 PHP 的 memory_limit 被突破。因此,可以使用另一种分配器(如 libc),但请注意:你的扩展将增加当前进程堆的大小。在 PHP 中使用 memory_get_usage() 无法看到这一点,但可以通过操作系统工具(如 /proc/{pid}/maps)分析当前堆来查看。

注意

如果你需要完全禁用 ZendMM,可以通过设置环境变量 USE_ZEND_ALLOC=0 来启动 PHP。这样,每次调用 ZendMM API(如 emalloc())都会被重定向到 libc 调用,从而禁用 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 会报告大量内存泄漏。这是因为当 PHP 非正常关闭时,引擎会使用 longjmp() 函数跳转到catch 块,从而阻止所有清理内存的代码执行。因此,会报告许多内存泄漏。这种情况在调用 PHP 的 exit()/die()函数后,或者在 PHP 的某些关键部分触发致命错误时尤其常见。

如果你使用的是非调试版的 PHP,标准错误输出(stderr)上不会有任何显示。ZendMM 虽然有些笨拙,但它仍然会清理所有已分配且与请求相关的缓冲区,这些缓冲区是程序员未明确释放的

你必须记住的是,ZendMM 泄漏跟踪是一个不错的附加工具,但它并不能取代真正的 C 内存调试器。

生命周期

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

在处理请求时,ZendMM 会根据需要分配数据块。

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

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

ZendMM 内部设计

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

内部内存组织

堆

在结构体 _zend_mm_heap 中定义的堆,包含指向区块(对于小分配和大分配,分别指向 main_chunk 和 cached_chunks)、指向用于大分配(≥2MB)的 huge_list 以及指向 free_slots[BIN] 中的 bins(用于小分配)的链接。初始化后,仅存在 main_chunk,没有或只有一些 cached_chunks。

内存块

每个块的大小为 2 MB,由 512 个内存页组成。每个块的第一页保留用于块头,如 struct _zend_mm_chunk(在 Zend/zend_alloc.c 中定义)中所述。块以带有前指针和后指针的链表形式组织。

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

内存页

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

Bin

小块分配被分组到不同的bin(内存块)中。bin 的大小是预定义的,共有 30 种不同的大小(8、16、24、32……3072 字节)。一个 bin 存放相同大小的数值,并直接从堆中链接。

一个 bin 可以包含多个页面。例如:存在一个 bin,其中包含的元素大小范围从 257 字节到 320 字节,该 bin 占用 5 个页面,因此有空间容纳 64 个(计算方法为4096*5/320)该大小的元素。

分配类别

小额分配

小于或等于 3072 字节的分配块被组织成 bin。

如果某个 bin 已经初始化,则 zend_mm_heap 结构体上的 free_slot 指针就是将要使用的地址(该地址将由 emalloc() 函数返回,并会递增以指向下一个空闲槽,具体实现请参见 zend_mm_alloc_small)。

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

大额分配

大于 3072 字节但足够小以放入一个区块(区块大小为 2MB,区块头(第一页)为 4096 字节,总计 2093056 字节)的分配直接存储在内存页中。在区块映射中,第一页将被标记为 LRUN,并且还会记录分配的页面数量。

巨额分配

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

挂接到 ZendMM

你可以调用 zend_mm_set_custom_handlers() 函数,并为其提供指向你的 malloc、free 和 realloc 处理程序的指针,以及指向你自定义堆或当前堆(可通过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 的行为会有所不同:

  • 如果你的自定义处理程序只是将调用转发给 ZendMM 内部函数,那么在 zend_mm_shutdown()(在 PHP 请求关闭阶段调用)期间,ZendMM 将不会运行清理操作,从而导致内存泄漏。
  • 在 zend_mm_gc() 中实现的 ZendMM 垃圾收集器将不会执行任何操作。这也意味着,如果你在 ZendMM 内部函数之一的分配过程中达到内存限制,它也不会尝试释放内存块。
  • 使用自定义处理程序检测堆中正在进行完全关闭的唯一方法是,使用堆的地址来调用 free 函数。
  • 无法确定 zend_mm_shutdown() 函数何时会执行请求关闭。

常见错误

以下是使用 ZendMM 时最常见的错误,以及针对这些错误你应该采取的措施。

1.没有处理请求,而使用了 ZendMM。

了解 PHP 的生命周期信息,以便在处理请求时了解你的扩展在何时介入,以及何时不介入。如果你在请求范围之外使用 ZendMM(例如在 MINIT()中),那么在处理第一个请求之前,ZendMM 会悄悄地清除分配,你可能会遇到释放后使用的问题:所以,请不要这样做。

2. 缓冲区溢出和下溢。

使用内存调试器。如果你在 ZendMM 返回的内存区域下方或之后写入,将会覆盖关键的 ZendMM 结构并触发崩溃。如果 ZendMM 能够为你检测到问题,可能会显示 “zend_mm_heap corrupted” 的消息。堆栈跟踪会显示从某个代码到某个 ZendMM 代码的崩溃路径。ZendMM 代码本身不会崩溃。如果你在 ZendMM 代码运行到一半时崩溃,那极有可能是你在某个地方搞错了指针。启动你最喜欢的内存调试器,查找出错的部分并修复它。

3.混合 API 调用

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

 

PHP