内存管理的数据结构

PHP 7 内存管理中用到了多个结构体,其中核心的结构体有 _zend_mm_heap、_zend_mm_page、_zend_mm_chunk。其中 _zend_mm_page 最简单,对应的是 4KB 的 char 数组,下面对 _zend_mm_heap 和 _zend_mm_chunk 进行详细的讨论。

_zend_mm_heap

变量存储在全局变量 alloc_globals(对应的宏是 AG())中的 mm_heap(在多线程模式下,会有多个 mm_heap 分别进行管理,为了容易理解,这里只介绍单线程模式下的 MM)字段所指向的数据中,其类型为 struct _zend_mm_heap,初始值为 NULL,在 MM 启动时进行初始化。下面一起来看下其主要数据结构。

struct _zend_mm_heap {
#if ZEND_MM_CUSTOM
    int use_custom_heap;
#endif
#if ZEND_MM_STORAGE
    zend_mm_storage   *storage;
#endif
#if ZEND_MM_STAT
    size_t size; /* 当前使用的内存大小 */
    size_t peak; /* 内存使用的峰值 */
#endif
    zend_mm_free_slot *free_slot[ZEND_MM_BINS]; /* 用来存储small内存 */
#if ZEND_MM_STAT || ZEND_MM_LIMIT
    size_t real_size; /* 当前真正分配的内存大小 */
#endif#
if ZEND_MM_STAT
    size_t real_peak; /* 真正分配的内存大小的峰值 */
#endif
#if ZEND_MM_LIMIT
    size_t limit; /* 内存限制的最大值 */
    int overflow; /* 内存溢出的标识 */
#endif

    zend_mm_huge_list *huge_list; /* huge内存的链表 */

    zend_mm_chunk *main_chunk;
    zend_mm_chunk *cached_chunks; /* 未使用chunk的链表 */
    int chunks_count; /* 分配chunk的个数 */
    int peak_chunks_count; /* 分配chunk个数的峰值*/
    int cached_chunks_count; /* 缓存chunk的格式 */
    double avg_chunks_count; /* 每个请求分配chunk的平均值*/

下面解释下各变量的含义。

  1. size/real_size:size 代表的是 MM 当前申请的已使用的内存,real_size 还包括申请的未使用的内存;可以通过 PHP 的函数 memory_get_usage 来获取,其 PHP 函数原型如下:

    int memory_get_usage ([ bool $real_usage = false ] )

    $real_usage 默认为 false,只返回使用的内存大小;对于 true 的情况,会返回包括没有使用的分配内存的大小。在 PHP 7 的源码中,有对应的实现:

    ZEND_API size_t zend_memory_usage(int real_usage)
    {
        if (real_usage) {
            return AG(mm_heap)->real_size;
        } else {
            size_t usage = AG(mm_heap)->size;
            return usage;
        }
        return 0;
    }

    从源码中,可以看出,当 $real_usage 为 true 时,返回的是 real_size;当 $real_usage 为 false 时,返回的是 size; size 和 real_size 会在申请和释放内存时进行修改。

  2. peak/real_peak:peak 是 emalloc 上报的内存峰值,而 real_peak 是 MM 在本进程申请的内存峰值;可以通过 PHP 的函数 memory_get_peak_usage 来获取,其 PHP 函数原型如下:

    int memory_get_peak_usage ([ bool $real_usage = false ] )

    $real_usage 默认为 false,只返回 emalloc 上报的内存峰值大小;对于 true 的情况,会返回内存分配峰值的大小;在 PHP 7 的源码中,有对应的实现:

    ZEND_API size_t zend_memory_peak_usage(int real_usage)
    {
        if (real_usage) {
            return AG(mm_heap)->real_peak;
        } else {
            return AG(mm_heap)->peak;
        }
        return 0;
    }

    从源码中,可以看出,当 $real_usage 为 true 时,返回的是 real_peak;当 $real_usage 为 false 时,返回的是 peak;同样 peak 和 real_peak 会在申请内存和释放内存时进行修改。

  3. free_slot:指针数组,存储 30 种规格的 small 内存链表的首地址,会在9.4.5节详细展开。

  4. limit:存储对 MM 可申请内存的最大值,MM 每当向系统申请 chunk 或 huge 内存时,会判断申请后的内存值是否大于 limit,如果大于,则进行垃圾回收。该参数可以通过在 php.ini 中修改 memory_limit 配置项设置。

  5. overflow:当要申请的内存总数超出 MM 的 limit 时,先进行垃圾回收,如果回收失败,则判断 overflow 是否为 1,如果为 1,则抛出异常,中断进程(PHP 项目中经常遇到的 “Allowed memory size of bytes exhausted (tried to allocate bytes)” 就是这样抛出来的)。

  6. main_chunk:双向链表,存储使用中的 chunk 的首地址。

  7. cached_chunks:双向链表,缓存的 chunk 的首地址。

  8. chunks_count:使用中的 chunk 个数,也就是链表 main_chunk 中的元素个数。

  9. peak_chunks_count:此次 HTTP 请求中申请的 chunk 个数最大值,初始化为 1,且每次请求开始,都会重置为 1。

  10. cached_chunks_count:缓存中的 chunk 个数,也就是链表 cached_chunks 中的元素个数。

  11. avg_chunks_count:历次请求使用 chunk 的个数平均值,初始化为 1.0,每次请求结束时,会重新计算此值,置为 avg_chunks_count 和 peak_chunks_count 的平均值。

    对于 chunk 相关的变量,会在后续的 chunk 章节详细展开。

  12. huge_list:用以挂载分配的大块内存的单向列表,方便后续 MM 关闭时释放。

结构体 _zend_mm_heap 本身是要占内存的,也保存在内存管理申请的内存中,我们来看下 zend_mm_heap:

(gdb) p *alloc_globals.mm_heap
$1 = {use_custom_heap = 0, storage = 0x0, size = 384000, peak = 384000, free_slot
    = {0x7ffff7c76000,
    0x7ffff7c79030, 0x7ffff7c55048, 0x7ffff7c5c700, 0x7ffff7c01528, 0x7ffff7c02210,
        0x7ffff7c56230,
    0x7ff f f7c621c0,  0x7f f f f7c750a0,  0x7f f f f7c74060,  0x0,  0x0,  0x7f f f f7c7a0a0,
        0x7ffff7c77240, 0x0,
    0x7ffff7c78300,  0x7ffff7c5d780,  0x0,  0x0,  0x0,  0x7ffff7c63000,  0x0,  0x0,
        0x0, 0x7ffff7c68000, 0x0,
    0x7ffff7c6d700, 0x0, 0x7ffff7c58400, 0x0}, real_size = 2097152, real_peak =
        2097152,
  limit = 134217728, overflow = 0, huge_list = 0x0, main_chunk = 0x7ffff7c00000,
      cached_chunks = 0x0,
    chunks_count = 1, peak_chunks_count = 1, cached_chunks_count = 0, avg_chunks_
        count = 1,
    custom_heap = {std = {_malloc = 0, _free = 0, _realloc = 0}, debug = {_malloc
        = 0, _free = 0,
        _realloc = 0}}}
  (gdb) p alloc_globals.mm_heap
  $2 = (zend_mm_heap *) 0x7ffff7c00040

从结果中看到,alloc_globals.mm_heap 的地址为 0x7ffff7c00040,而 main_chunk 的地址为 0x7ffff7c00000,可以看出 mm_heap 其实是在 main_chunk 上分配的。根据 gdb 得到的信息,可以画出 main_chunk 占用内存的情况,如图9-4所示。

image 2024 06 10 13 31 13 877
Figure 1. 图9-4 PHP 7内存地址对齐示例

从图9-4中可以看出,结构体按8对齐后,mm_heap 要占 376B 的内存,通过 gdb 可以验证:

(gdb) p sizeof(*alloc_globals.mm_heap)
$3 = 376

_zend_mm_heap 中有一个非常重要的结构——_zend_mm_chunk,下面讨论一下这个结构体。

_zend_mm_chunk

PHP 7 的 MM 是一个多级内存分配器——预先定义内存块级别,按需要分配空间的大小找到对应级别,对齐分配。前文提到,chunk 大小为 2MB;每个 chunk 可以切割为 512 个 page,一个 page 是 4KB。在 chunk 内部,以 page 为单位进行管理。参考以下宏:

#define ZEND_MM_CHUNK_SIZE (2 * 1024 * 1024)/* 2 MB  */
#define ZEND_MM_PAGE_SIZE (4 * 1024) /* 4 KB  */
#define ZEND_MM_PAGES (ZEND_MM_CHUNK_SIZE / ZEND_MM_PAGE_SIZE) /* 512 */

一个 chunk 大小为 2MB, MM 管理 chunk 的变量,使用的是结构体 _zend_mm_chunk:

struct _zend_mm_chunk {
    zend_mm_heap *heap;
    zend_mm_chunk *next;
    zend_mm_chunk *prev;
    uint32_t free_pages; /* free pages的个数*/
    uint32_t free_tail; /* 尾部chunk上free pages的个数*/
    uint32_t num;
    char reserve[64- (sizeof(void*) * 3 + sizeof(int) * 3)];
    zend_mm_heap heap_slot; /* 只存在main chunk上 */
    zend_mm_page_map free_map; /* 512 bits或 64 bytes */
    zend_mm_page_info map[ZEND_MM_PAGES]; /* 2 KB = 512 * 4 */
    };
struct _zend_mm_page {
    char                bytes[ZEND_MM_PAGE_SIZE]; // ZEND_MM_PAGE_SIZE为4KB
};

各变量的含义如下。

  1. heap:zend_mm_heap 类型的指针,对应的是9.3.1节中 AG 里面的 mm_heap 的地址。

  2. next:zend_mm_chunk 类型的指针,指向下一个 chunk。

  3. prev:zend_mm_chunk 类型的指针,指向上一个 chunk。由 next/prev 可见 zend_mm_chunk 是双向链表。

  4. free_pages:此 chunk 中可用的 page 个数,如图9-5所示,此 chunk 一共使用了 9 个 page,则 free_pages 为 512-9=503。

    image 2024 06 10 13 35 15 316
    Figure 2. 图9-5 PHP 7内存管理page使用情况示例
  5. free_tail:此 chunk 的最后一块连续可用 page 的起始编号,主要用于快速查找连续可用 page,此值并不准确,但不影响最后结果,如图9-5所示,free_tail 应该为 363。

  6. free_map:在 64 位机器下,其为 8 个元素的数组,每个元素为 64bit 的整型,所以一共有 8×64bit=512bit,对应 512 个 page。已使用的 page,对应的 bit 置为 1,灰色部分;未使用(可用)的 page,对应的 bit 置为 0,白色部分,如图9-6所示。

    image 2024 06 10 13 36 31 123
    Figure 3. 图9-6 free_map对应的512bit
  7. map:512 个元素的数组,每个元素为一个 32bit 的整型,用来记录每个 page 的使用情况,比较复杂,如图9-7所示。

    image 2024 06 10 13 37 13 244
    Figure 4. 图9-7 PHP 7内存管理large内存的map使用情况示例

    高位的 2 个 bit,用于标记此 page 的使用类型,有 4 种情况:0x0、0x1、0x2、0x3,其中 0x0 代表此 page 未使用,0x1 代表此 page 用于 large 内存,0x2 和 0x3 均代表此 page 用于 small 内存。

    当此 page 用于 large 内存时,如果低位的 10 个 bit 为 0,则代表此 page 被其前面且连续的 page 一起用于一次申请的内存;如果非 0,假定值为 page_count,则代表此 page 开始的连续 page_count 个 page 一起用于一次申请的内存,比如图9-6中一次申请了 3 个连续的 page,起始编号为 360,那么 map[360]、map[361]、map[362] 的低 10 位分别为 3、0、0。

    当此 page 用于 small 内存时,在 9.4.5 节中介绍此字段。

    free_map 是 8×8B,也就是 8×8×8=512bit,这 512 个 bit 对应 512 个 page,每个 bit 只能取 0 或者 1,代表对应 page 的使用情况。而 map 是 512 个 uint32_t,也就是 512×4B,每一个 uint32_t 代表一个 page 的使用情况。

  8. num:代表此 chunk 在链表 main_chunk 中的编号,很明显,当申请第一个 chunk 时,num 为 0。对于非第一个 chunk, num 的值为在前一个 chunk 的 num 上加 1。

  9. reserve:保留字段,在 C 语言开发中的结构体中尤为常见,用于结构体版本升级之类。

  10. heap_slot:在 MM 进行初始化时,会创建第一个 chunk,而第一个 chunk 的此字段,才有意义。其实全局指针 alloc_globals.mm_heap 指向的便是第一个 chunk 的 heap_slot。

每申请一个 chunk,都需要对 chunk 进行初始化,大致流程如下所示。

  1. 将此 chunk 放入环状双向链表 main_chunk 的最后面。

  2. 将 free_pages 置为 512-1=511(第 0 个 page 被 chunk 的头信息占用)。

  3. 将 free_tail 置为 1。

  4. 将 num 在上一个元素的计数基础上加 1(chunk→prev→num+1)。

  5. 将 free_map[0] 标记为 1,代表第 0 个被使用。

  6. 将 map[0] 标记为 0x40000000 | 0x01,0x40000000 代表第 0 个 page 使用 large 内存,0x01 代表从第 0 个 page 起,连续 1 个 page 被使用。

_zend_mm_chunk 本身是要占用内存的,我们输出 _zend_mm_chunk 的 size:

(gdb) p sizeof(zend_mm_chunk)
$3 = 2552

这个结构体占了 2552B,它存放在 chunk 的第 0 个 page 上,如图9-8所示。

image 2024 06 10 13 43 26 323
Figure 5. 图9-8 PHP 7内存管理chunk和page在MM中的位置

当申请一个 chunk 时,MM 先判断双向链表 cached_chunks 是否存在 chunk,如果不存在,则直接向操作系统申请一个地址以 2MB 对齐的 chunk,添加到 main_chunk 中,然后返回给申请者;如果 cached_chunks 中存在 chunk,则将头部的 chunk 摘除,然后添加到 main_chunk 后,返回给申请者。每次有新的 chunk 进入 main_chunk 之前,都需要对此 chunk 进行初始化,一个 chunk 被分成 512 个 page,其中 511 个 page 可用,第 0 个 page 用来存储这个 chunk 的管理结构体 struct_zend_mm_chunk。

释放一个 chunk 时,MM 先将此 chunk 从 main_chunk 中移除,并将 chunks_count 减 1。然后判断当前使用的 chunk 个数 chunks_count 和缓存中的 chunk 个数 cached_chunks_count 之和是否小于历次请求使用的 chunk 个数平均值 avg_chunks_count。如果小于,则将此 chunk 放入双向链表 cached_chunks 中;如果不小于,则直接向操作系统释放此块内存。

到此,我们研究了 AG 里面 mm_heap 的结构,以及 chunk 和 page 结构和相互关系,有了这些准备后,再来看下 PHP 7 内存管理的详细实现。