PHP内核详解· 内存管理篇(三)· 分配大块内存

一、大块内存的分配过程

分配过程从 zend_mm_alloc_large()函数开始。该函数负责处理介于 **ZEND_MM_MAX_SMALL_SIZE**(3072B)**ZEND_MM_MAX_LARGE_SIZE**(2MB - 512B) 之间的内存请求。这类请求被归类为"大块内存(large block)",它们既不属于小块路径,也未达到巨大块的系统映射阈值。

zend_mm_alloc_large()函数本身是一个中转层,将任务转交给 zend_mm_alloc_large_ex()函数。这样的设计体现出 Zend 内核一贯的工程哲学------逻辑分层清晰,执行动作简洁。

调用路径如下:

scss 复制代码
zend_mm_alloc_large()
 └── zend_mm_alloc_large_ex()
      └── zend_mm_alloc_pages()

zend_mm_alloc_large_ex()函数内部,ZEND_MM_SIZE_TO_NUM()宏负责将字节数转换为页数。随后,分配逻辑进入 zend_mm_alloc_pages()函数,查找 chunk 中可连续分配的页段。这一步开始,分配器几乎在直接操作物理页。

zend_mm_alloc_pages()函数是整个流程的核心部分。其逻辑简洁明晰:

  1. 分段遍历 bitset 地图,计算连续空闲页;

  2. 若当前 chunk 空间不足,则转向下一个 chunk,必要时创建新 chunk;

  3. 找到合适区间后,标记相应页为已用,并返回指针。

这一流程展示了 Zend 对性能与秩序的平衡。它在最小扫描代价下完成最优分配,展现出底层代码的克制与高效。当分配完成时,大块内存已顺利落地。

二、bitset 地图

在 PHP 的内存分配机制中,无论是大块内存还是小块内存,都离不开 bitset 的参与。顾名思义,bitset 是一组二进制位的集合,用来标记一个 chunk 中的每个 page 是否已被使用。

在 Zend 内存管理中,zend_mm_bitset 是无符号整数 zend_ulong 的别名,其相关定义如下:

arduino 复制代码
// 在 32 位操作系统中占 4 字节,在 64 位系统中占 8 字节
typedef zend_ulong zend_mm_bitset;    /* 4-byte or 8-byte integer */

// 每个 BITSET 的长度:32 位系统中为 32,64 位系统中为 64
#define ZEND_MM_BITSET_LEN (sizeof(zend_mm_bitset) * 8)

// PAGE_MAP 的长度:32 位系统为 512/32=16,64 位系统为 512/64=8
#define ZEND_MM_PAGE_MAP_LEN (ZEND_MM_PAGES / ZEND_MM_BITSET_LEN)

// 32 位系统:4B * 16 = 64B
// 64 位系统:8B * 8 = 64B,总计 512 个 bit。
typedef zend_mm_bitset zend_mm_page_map[ZEND_MM_PAGE_MAP_LEN];    /* 64B */

在 32 位和 64 位系统中,zend_mm_page_map 的大小都是 64 字节,共包含 512 个 bit,对应一个 chunk 中的 512 个 page。当某个位为 0 时,表示对应序号的 page 空闲;当为 1 时,表示该 page 已被使用。


bitset 本身是一个非常轻量的结构,但它支撑着整个内存分配系统的精度与性能。Zend 为其定义了一系列操作函数,主要集中在 zend_alloc.c 中:

arduino 复制代码
// 计算 bitset 右侧连续 1 的数量
static zend_always_inline int zend_mm_bitset_nts(zend_mm_bitset bitset);

// 检查某一位是否为 1
static zend_always_inline int zend_mm_bitset_is_set(zend_mm_bitset *bitset, int bit);

// 设置某一位为 1
static zend_always_inline void zend_mm_bitset_set_bit(zend_mm_bitset *bitset, int bit);

// 设置某一位为 0
static zend_always_inline void zend_mm_bitset_reset_bit(zend_mm_bitset *bitset, int bit);

// 设置某一区域为 1
static zend_always_inline void zend_mm_bitset_set_range(zend_mm_bitset *bitset, int start, int len);

// 设置某一区域为 0
static zend_always_inline void zend_mm_bitset_reset_range(zend_mm_bitset *bitset, int start, int len);

// 检查某一区域是否为空闲
static zend_always_inline int zend_mm_bitset_is_free_range(zend_mm_bitset *bitset, int start, int len);

除此之外,还有定义在 zend_portability.h 中的 ZEND_BIT_TEST 宏,它虽然只有一行代码,却考虑到了 32 位和 64 位系统的兼容性:

scss 复制代码
#define ZEND_BIT_TEST(bits, bit) \
    (((bits)[(bit) / (sizeof((bits)[0])*8)] >> ((bit) & (sizeof((bits)[0])*8 - 1))) & 1)

以 64 位系统为例,其逻辑可分解为:

scss 复制代码
(((bits)[页编号] >> (bit & 63)) & 1)

这行代码取出对应页所在整数,并通过右移与按位与操作,判断该 bit 是否被设置。


从这些方法的定义可以看出,bitset 的操作单位是单个 zend_mm_bitset,而非整张 zend_mm_page_map。为了方便理解,可以将 zend_mm_page_map 看作一本"地图册",每个 zend_mm_bitset 则是其中的一页。在操作时,传入第一页的指针即可完成对整张地图的遍历与修改。例如:

scss 复制代码
zend_mm_bitset_is_set(chunk->free_map, i);

系统会自动定位到需要操作的页码。这种设计使得代码简洁高效,不需要额外的偏移计算。


值得注意的是,内存中 bit 的排列顺序与逻辑上的顺序并不完全一致。zend_mm_page_map 中的 bitset 从左到右排列,而单个 zend_mm_bitset 内部的 bit 是"高位在左、低位在右"。因此,在计算或设置位时,通常需要从右向左遍历。例如:

arduino 复制代码
static zend_always_inline void zend_mm_bitset_set_bit(zend_mm_bitset *bitset, int bit) {
    bitset[bit / ZEND_MM_BITSET_LEN] |= (Z_UL(1) << (bit & (ZEND_MM_BITSET_LEN - 1)));
}

以 64 位系统为例,该操作等价于:

arduino 复制代码
bitset[块索引] |= 1 << (bit & 63);

zend_mm_bitset_nts() 函数的逻辑同样体现了这一特性。它计算的是 bitset 右端连续 1 的数量,也就是逻辑上的"开头"部分。这种位序的反向定义虽然看似复杂,却能更高效地映射底层 CPU 的位操作方式。


总的来说,bitset 的设计看似简单,但却是整个内存管理系统的基础。通过 64 字节的地图,就能标记一个 2MB chunk 的 512 个 page 使用状态,极大地提高了空间利用率与访问效率。这样的巧妙结构背后,凝结的是底层计算机体系结构的精妙知识。

三、分段遍历 bitset 地图,查找可用空间

在内存分配的过程中,需要在 chunk 的 bitset 地图中找到一段连续的空闲 page。bitset 是一个二进制位数组,用于标记每个 page 是否被占用。分段遍历是这一过程的核心。

整个过程从遍历 chunk 链表开始,逐一分析每个 bitset 的状态。每个 bitset 都由若干段组成,这些段由连续的 1(已使用)和连续的 0(空闲)交替构成。每一段的末尾一定是 0 或地图的结尾。当系统检测到一段连续的 0 数量大于或等于所需的 page 数量时,就表示找到了可用的空间。

例如,在 32 位系统中,一个 zend_mm_bitset 占 4 字节(32 bit),其存储示意如下:

找到可用空间后,系统会记录其起始位置,作为下一步分配的候选点。

整个过程的主体逻辑上比较简洁,但为提升效率又做了以下几项比较复杂的优化。

1)在所有段中查找最佳可用位置

为了提高空间利用率,系统不会盲目使用第一个符合条件的空闲段,而是会遍历所有可用段,找到最接近目标大小的那一段。这被称为"最佳可用位置"。

例如,当需要分配 3 个 page 时,假设段 3 和段 4 都能满足要求,段 3 有 5 个空闲 page,段 4 有 13 个空闲 page,那么段 3 更加合适。这样的选择可以显著提升内存利用率。

在遍历过程中,如果系统找到的空闲段大小正好等于所需 page 数量,就可以立即停止查找;若未命中,则记录所有候选段,并不断更新距离最接近的那个位置。

这是一种典型的"平衡式优化"策略:既追求分配的即时性,又兼顾空间的合理利用。

2)段的切换

在 bitset 遍历中,从一个段切换到下一个段的过程非常频繁。为此,Zend 使用了极为精巧的位运算技巧,只需两条语句即可完成切换:

arduino 复制代码
tmp &= tmp + 1;  // 把右侧连续的 1 变成 0,跳到下一段的起始位置
tmp |= tmp - 1;  // 把右侧连续的 0 变成 1,跳到下一个空闲段的开头

以示例 bitset 为例:

dart 复制代码
// 初始 bitset:11100000 00000000 11110000 01001100
// 第一次循环:段 1(序号 1~2)有 2 个空闲 page
tmp &= tmp + 1; => 11100000 00000000 11110000 01001100
tmp |= tmp - 1; => 11100000 00000000 11110000 01001111
// 下一次循环时:段 2(序号 1~6)有 2 个空闲位置
tmp &= tmp + 1; => 11100000 00000000 11110000 01000000
tmp |= tmp - 1; => 11100000 00000000 11110000 01111111
// 下一次循环时:段 3(序号 1~12)有 5 个空闲位置
tmp &= tmp + 1; => 11100000 00000000 11110000 00000000
tmp |= tmp - 1; => 11100000 00000000 11111111 11111111
// 下一次循环时:段 4(序号 1~30)有 13 个空闲位置
tmp &= tmp + 1; => 11100000 00000000 00000000 00000000
tmp |= tmp - 1; => 11111111111111111111111111111111111
// 下一次循环时:检查到此chunk中没有可用位置,会跳过此chunk到下一chunk。

通过这种跳跃式的位运算方式,系统不再需要逐位扫描,而是"成段"地移动,大幅降低遍历次数。这是对性能的一种极致优化。

3)zend_mm_bitset_nts() 函数

zend_mm_bitset_nts() 函数用于计算一个 bitset 右侧(即开头方向)连续的 1 的数量。其实现代码如下:

c 复制代码
static zend_always_inline int zend_mm_bitset_nts(zend_mm_bitset bitset){
    int n;
    // 如果所有位都是 1,直接返回长度
    if (bitset == (zend_mm_bitset)-1) return ZEND_MM_BITSET_LEN;
    n = 0;
#if SIZEOF_ZEND_LONG == 8 // 如果是 64 位系统
    if (sizeof(zend_mm_bitset) == 8) {
        if ((bitset & 0xffffffff) == 0xffffffff) { n += 32; bitset = bitset >> Z_UL(32); }
    }
#endif
    if ((bitset & 0x0000ffff) == 0x0000ffff) { n += 16; bitset = bitset >> 16; }
    if ((bitset & 0x000000ff) == 0x000000ff) { n += 8; bitset = bitset >> 8; }
    if ((bitset & 0x0000000f) == 0x0000000f) { n += 4; bitset = bitset >> 4; }
    if ((bitset & 0x00000003) == 0x00000003) { n += 2; bitset = bitset >> 2; }
    return n + (bitset & 1);
}

可以看到,Zend 使用了分组掩码与右移结合的方式,实际上是一种"分段二分查找"。32 位系统最多比较 5 次,64 位系统最多比较 6 次即可完成计算。这种方法在性能和代码可读性之间取得了完美的平衡。

而在计算右端连续 0 的数量时,使用 zend_ulong_ntz() 方法,逻辑完全相似。

4)zend_mm_bitset 的切换

在进入新的页时,Zend 通过整数级的判断快速略过无效页:

  • 若页值等于 (zend_mm_bitset)-1,表示该页的所有位均为 1,可直接跳过;
  • 若页值等于 0,表示该页全部空闲,也可整体略过。

这种设计使得系统只需一次整数比较,就能判断 32 或 64 个 bit 的状态,而无需调用任何复杂函数:

scss 复制代码
zend_mm_alloc_small() -> zend_mm_alloc_small_slow() -> zend_mm_alloc_pages()

这种对极限性能的追求,是 Zend 内存分配器最值得称道的部分之一。

5)zend_mm_chunk 结构体中的 free_tail

free_tail 是一个简单但重要的优化字段。它记录了当前 chunk 中最后一段空闲 page 的起始索引。例如:

在遍历 bitset 时,只需扫描到 free_tail 位置即可,后续部分可以直接跳过。这样能有效避免无效的循环与判断,尤其在大块内存频繁分配与回收时,性能提升明显。

当以上这些机制协同工作时,bitset 的遍历速度极快,内存分配的效率几乎达到了物理结构允许的上限。

四、chunk 空间不够时的处理

在 PHP 内存分配过程中,当当前 chunk 中的可用空间不足时,系统会按照优先级分层处理,以最大程度地提升性能并减少系统调用开销。整体策略可以概括为:"先查找、再复用、最后创建"。

1)尝试查找后续 chunk

如果当前 chunk 的 next 指针没有指向 main_chunk,说明当前 chunk 并不是链表中的最后一个节点。此时可以直接切换到下一个 chunk,继续在其中查找可用空间:

ini 复制代码
chunk = chunk->next; // 切换到下一个 chunk
steps++;              // 记录前进 1 步

这种顺序遍历的策略可以在已有的内存块中完成分配,避免频繁的系统调用,从而提升整体分配效率。

2)尝试使用缓存 chunk

next 指针指向 main_chunk 时,说明链表中已没有可用的 chunk。此时系统会尝试从缓存列表中复用已经释放但仍保留在内存中的 chunk。

Zend 内存管理器不会立即释放不再使用的 chunk,而是将其放入缓存列表中,以便后续快速复用。只有当缓存数量超过限制时,才会真正释放多余的 chunk。这是一种典型的"用空间换时间"的策略。

rust 复制代码
if (heap->cached_chunks) {           // 如果有缓存 chunk,优先使用它
    heap->cached_chunks_count--;     // 缓存数量减 1
    chunk = heap->cached_chunks;     // 获取缓存 chunk
    heap->cached_chunks = chunk->next; // 更新缓存链表头指针
}

通过这种机制,系统在高频分配和释放的场景下,能有效降低系统调用次数,减少碎片化和锁竞争,显著提升分配性能。

3)创建并初始化新的 chunk

如果缓存中也没有可复用的 chunk,Zend 会创建一个新的 chunk。整个流程包括:检查内存限制、创建 chunk、初始化结构并加入链表。

在创建之前,系统会先检查当前已分配的内存是否接近上限。如果存在超限风险,则会调用 zend_mm_gc() 进行垃圾回收:

less 复制代码
// 如果可用内存小于 ZEND_MM_CHUNK_SIZE,说明空间不足
if (UNEXPECTED(ZEND_MM_CHUNK_SIZE > heap->limit - heap->real_size)) {
    if (zend_mm_gc(heap)) { // 回收空闲内存
        ...
    }
}

如果内存仍然充足,系统会调用 zend_mm_chunk_alloc() 分配一个新的 chunk,其调用路径如下:

scss 复制代码
zend_mm_chunk_alloc() -> zend_mm_chunk_alloc_int()

每个新创建的 chunk 大小为 ZEND_MM_CHUNK_SIZE = 2MB

创建完成后,需要更新 heap 的相关记录:包括当前内存使用量 (real_size) 和 chunk 数量等。随后,调用 zend_mm_chunk_init() 对新 chunk 进行初始化:

ini 复制代码
static zend_always_inline void zend_mm_chunk_init(zend_mm_heap *heap, zend_mm_chunk *chunk) {
    // 绑定所属 heap
    chunk->heap = heap;

    // 将新 chunk 挂载到链表末尾,形成循环结构
    chunk->next = heap->main_chunk;
    chunk->prev = heap->main_chunk->prev;
    chunk->prev->next = chunk;
    chunk->next->prev = chunk;

    // 初始化空闲页信息
    chunk->free_pages = ZEND_MM_PAGES - ZEND_MM_FIRST_PAGE;
    chunk->free_tail = ZEND_MM_FIRST_PAGE; // 初始空闲尾页为 1

    // 为新 chunk 分配编号
    chunk->num = chunk->prev->num + 1;

    // 将第一个 page 标记为已使用
    chunk->free_map[0] = (1L << ZEND_MM_FIRST_PAGE) - 1;

    // 在 map 中添加大块内存标记(LRUN)
    chunk->map[0] = ZEND_MM_LRUN(ZEND_MM_FIRST_PAGE);
}

值得注意的是,新 chunk 的第一个 page 会被保留,用作系统标识或管理用途,并不会参与实际分配。这保证了 chunk 的结构完整性和后续管理的一致性。

通过以上三步处理流程,Zend 内存管理器能在几乎所有情况下保持分配过程的高效与平稳。它避免了频繁的系统调用,降低了内存碎片率,也让整个分配器在面对复杂、高并发的 PHP 应用时,依然能稳定运行。

五、在 chunk 中分配需要的 page

当可分配的 chunk 已经确定后,接下来的任务是在其中拿到一串连续的 page 并完成状态更新。策略很朴素:尽量把高频的小块分配放在链表前端,随后一次性更新 bitmap 与元数据,最后返回首个 page 的地址。

首先做一个微优化:如果本次需要的 page 数少于 8,说明属于小块分配场景。为了降低后续查找开销,应将当前 chunk 移至链表前端,使其紧随 main_chunk。小块分配在典型业务中出现频率最高,把"热" chunk 放在最前面更容易命中,提高整体吞吐。

随后进行收尾更新。核心动作包括三件事:

  • 调整空闲页计数;
  • 在 free_map 上批量标记这段 page 已被占用;
  • 在 map 中写入"大块占用条目"的元信息,并维护 free_tail 指针(若命中尾段)。

实现片段如下(示意):

scss 复制代码
// 更新剩余 page 数量
chunk->free_pages -= pages_count;

// 在 free_map 上将 [page_num, page_num + pages_count) 标记为占用
zend_mm_bitset_set_range(chunk->free_map, page_num, pages_count); // 函数

// 在 map 中登记一条大块占用记录(记录 run 长度)
chunk->map[page_num] = ZEND_MM_LRUN(pages_count); // 宏

// 若恰好从尾段开头分配,则推进尾指针
if (page_num == chunk->free_tail) {
    chunk->free_tail = page_num + pages_count;
}

// 计算并返回首个 page 的地址
return ZEND_MM_PAGE_ADDR(chunk, page_num); // 宏

至此,一次"大块内存"分配闭环完成:从定位 chunk、选段、批量标记到返回可用指针,路径短、修改点集中,便于后续统计与回收。

整个分配机制依旧遵循"先粗后细"的策略------先定位大的空间(chunk),再确认页级位置,最后在页内完成分配。这正是 Zend 的工程美学:结构清晰,逻辑自洽,冷静而优雅。

相关推荐
JaguarJack4 小时前
PHP 的异步编程 该怎么选择
后端·php·服务端
BingoGo4 小时前
PHP 的异步编程 该怎么选择
后端·php
JaguarJack19 小时前
为什么 PHP 闭包要加 static?
后端·php·服务端
ServBay2 天前
垃圾堆里编码?真的不要怪 PHP 不行
后端·php
用户962377954482 天前
CTF 伪协议
php
BingoGo4 天前
当你的 PHP 应用的 API 没有限流时会发生什么?
后端·php
JaguarJack4 天前
当你的 PHP 应用的 API 没有限流时会发生什么?
后端·php·服务端
BingoGo5 天前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php
JaguarJack5 天前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php·服务端
JaguarJack6 天前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
后端·php·服务端