我的兵与城(内置功能菜单)
86.36MB · 2025-10-29
分配过程从 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 内核一贯的工程哲学——逻辑分层清晰,执行动作简洁。
调用路径如下:
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()函数是整个流程的核心部分。其逻辑简洁明晰:
分段遍历 bitset 地图,计算连续空闲页;
若当前 chunk 空间不足,则转向下一个 chunk,必要时创建新 chunk;
找到合适区间后,标记相应页为已用,并返回指针。
这一流程展示了 Zend 对性能与秩序的平衡。它在最小扫描代价下完成最优分配,展现出底层代码的克制与高效。当分配完成时,大块内存已顺利落地。
在 PHP 的内存分配机制中,无论是大块内存还是小块内存,都离不开 bitset 的参与。顾名思义,bitset 是一组二进制位的集合,用来标记一个 chunk 中的每个 page 是否已被使用。
在 Zend 内存管理中,zend_mm_bitset 是无符号整数 zend_ulong 的别名,其相关定义如下:
// 在 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 中:
// 计算 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 位系统的兼容性:
#define ZEND_BIT_TEST(bits, bit)
(((bits)[(bit) / (sizeof((bits)[0])*8)] >> ((bit) & (sizeof((bits)[0])*8 - 1))) & 1)
以 64 位系统为例,其逻辑可分解为:
(((bits)[页编号] >> (bit & 63)) & 1)
这行代码取出对应页所在整数,并通过右移与按位与操作,判断该 bit 是否被设置。
从这些方法的定义可以看出,bitset 的操作单位是单个 zend_mm_bitset,而非整张 zend_mm_page_map。为了方便理解,可以将 zend_mm_page_map 看作一本“地图册”,每个 zend_mm_bitset 则是其中的一页。在操作时,传入第一页的指针即可完成对整张地图的遍历与修改。例如:
zend_mm_bitset_is_set(chunk->free_map, i);
系统会自动定位到需要操作的页码。这种设计使得代码简洁高效,不需要额外的偏移计算。
值得注意的是,内存中 bit 的排列顺序与逻辑上的顺序并不完全一致。zend_mm_page_map 中的 bitset 从左到右排列,而单个 zend_mm_bitset 内部的 bit 是“高位在左、低位在右”。因此,在计算或设置位时,通常需要从右向左遍历。例如:
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 位系统为例,该操作等价于:
bitset[块索引] |= 1 << (bit & 63);
zend_mm_bitset_nts() 函数的逻辑同样体现了这一特性。它计算的是 bitset 右端连续 1 的数量,也就是逻辑上的“开头”部分。这种位序的反向定义虽然看似复杂,却能更高效地映射底层 CPU 的位操作方式。
总的来说,bitset 的设计看似简单,但却是整个内存管理系统的基础。通过 64 字节的地图,就能标记一个 2MB chunk 的 512 个 page 使用状态,极大地提高了空间利用率与访问效率。这样的巧妙结构背后,凝结的是底层计算机体系结构的精妙知识。
在内存分配的过程中,需要在 chunk 的 bitset 地图中找到一段连续的空闲 page。bitset 是一个二进制位数组,用于标记每个 page 是否被占用。分段遍历是这一过程的核心。
整个过程从遍历 chunk 链表开始,逐一分析每个 bitset 的状态。每个 bitset 都由若干段组成,这些段由连续的 1(已使用)和连续的 0(空闲)交替构成。每一段的末尾一定是 0 或地图的结尾。当系统检测到一段连续的 0 数量大于或等于所需的 page 数量时,就表示找到了可用的空间。
例如,在 32 位系统中,一个 zend_mm_bitset 占 4 字节(32 bit),其存储示意如下:
找到可用空间后,系统会记录其起始位置,作为下一步分配的候选点。
整个过程的主体逻辑上比较简洁,但为提升效率又做了以下几项比较复杂的优化。
为了提高空间利用率,系统不会盲目使用第一个符合条件的空闲段,而是会遍历所有可用段,找到最接近目标大小的那一段。这被称为“最佳可用位置”。
例如,当需要分配 3 个 page 时,假设段 3 和段 4 都能满足要求,段 3 有 5 个空闲 page,段 4 有 13 个空闲 page,那么段 3 更加合适。这样的选择可以显著提升内存利用率。
在遍历过程中,如果系统找到的空闲段大小正好等于所需 page 数量,就可以立即停止查找;若未命中,则记录所有候选段,并不断更新距离最接近的那个位置。
这是一种典型的“平衡式优化”策略:既追求分配的即时性,又兼顾空间的合理利用。
在 bitset 遍历中,从一个段切换到下一个段的过程非常频繁。为此,Zend 使用了极为精巧的位运算技巧,只需两条语句即可完成切换:
tmp &= tmp + 1; // 把右侧连续的 1 变成 0,跳到下一段的起始位置
tmp |= tmp - 1; // 把右侧连续的 0 变成 1,跳到下一个空闲段的开头
以示例 bitset 为例:
// 初始 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。
通过这种跳跃式的位运算方式,系统不再需要逐位扫描,而是“成段”地移动,大幅降低遍历次数。这是对性能的一种极致优化。
zend_mm_bitset_nts() 函数zend_mm_bitset_nts() 函数用于计算一个 bitset 右侧(即开头方向)连续的 1 的数量。其实现代码如下:
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() 方法,逻辑完全相似。
zend_mm_bitset 的切换在进入新的页时,Zend 通过整数级的判断快速略过无效页:
(zend_mm_bitset)-1,表示该页的所有位均为 1,可直接跳过;这种设计使得系统只需一次整数比较,就能判断 32 或 64 个 bit 的状态,而无需调用任何复杂函数:
zend_mm_alloc_small() -> zend_mm_alloc_small_slow() -> zend_mm_alloc_pages()
这种对极限性能的追求,是 Zend 内存分配器最值得称道的部分之一。
zend_mm_chunk 结构体中的 free_tailfree_tail 是一个简单但重要的优化字段。它记录了当前 chunk 中最后一段空闲 page 的起始索引。例如:
在遍历 bitset 时,只需扫描到 free_tail 位置即可,后续部分可以直接跳过。这样能有效避免无效的循环与判断,尤其在大块内存频繁分配与回收时,性能提升明显。
在 PHP 内存分配过程中,当当前 chunk 中的可用空间不足时,系统会按照优先级分层处理,以最大程度地提升性能并减少系统调用开销。整体策略可以概括为:“先查找、再复用、最后创建”。
如果当前 chunk 的 next 指针没有指向 main_chunk,说明当前 chunk 并不是链表中的最后一个节点。此时可以直接切换到下一个 chunk,继续在其中查找可用空间:
chunk = chunk->next; // 切换到下一个 chunk
steps++; // 记录前进 1 步
这种顺序遍历的策略可以在已有的内存块中完成分配,避免频繁的系统调用,从而提升整体分配效率。
当 next 指针指向 main_chunk 时,说明链表中已没有可用的 chunk。此时系统会尝试从缓存列表中复用已经释放但仍保留在内存中的 chunk。
Zend 内存管理器不会立即释放不再使用的 chunk,而是将其放入缓存列表中,以便后续快速复用。只有当缓存数量超过限制时,才会真正释放多余的 chunk。这是一种典型的“用空间换时间”的策略。
if (heap->cached_chunks) { // 如果有缓存 chunk,优先使用它
heap->cached_chunks_count--; // 缓存数量减 1
chunk = heap->cached_chunks; // 获取缓存 chunk
heap->cached_chunks = chunk->next; // 更新缓存链表头指针
}
通过这种机制,系统在高频分配和释放的场景下,能有效降低系统调用次数,减少碎片化和锁竞争,显著提升分配性能。
如果缓存中也没有可复用的 chunk,Zend 会创建一个新的 chunk。整个流程包括:检查内存限制、创建 chunk、初始化结构并加入链表。
在创建之前,系统会先检查当前已分配的内存是否接近上限。如果存在超限风险,则会调用 zend_mm_gc() 进行垃圾回收:
// 如果可用内存小于 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,其调用路径如下:
zend_mm_chunk_alloc() -> zend_mm_chunk_alloc_int()
每个新创建的 chunk 大小为 ZEND_MM_CHUNK_SIZE = 2MB。
创建完成后,需要更新 heap 的相关记录:包括当前内存使用量 (real_size) 和 chunk 数量等。随后,调用 zend_mm_chunk_init() 对新 chunk 进行初始化:
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 的结构完整性和后续管理的一致性。
当可分配的 chunk 已经确定后,接下来的任务是在其中拿到一串连续的 page 并完成状态更新。策略很朴素:尽量把高频的小块分配放在链表前端,随后一次性更新 bitmap 与元数据,最后返回首个 page 的地址。
首先做一个微优化:如果本次需要的 page 数少于 8,说明属于小块分配场景。为了降低后续查找开销,应将当前 chunk 移至链表前端,使其紧随 main_chunk。小块分配在典型业务中出现频率最高,把“热” chunk 放在最前面更容易命中,提高整体吞吐。
随后进行收尾更新。核心动作包括三件事:
实现片段如下(示意):
// 更新剩余 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、选段、批量标记到返回可用指针,路径短、修改点集中,便于后续统计与回收。