PHP Zend Engine 内核解读:Zend 内存管理器与 ZEND_VM 操作码执行架构(基于 Zend/README.md)
本文基于 php-src 仓库中的 Zend/README.md 展开,完整继承其中两大核心主题:Zend 内存管理器(memory manager)的设计目标与调试手段,以及 ZEND_VM 操作码执行器的专用化(specialization)、线程化模型(threading)与生成机制,并结合 Zend/zend_alloc.c、Zend/zend_vm_def.h、Zend/zend_vm_gen.php 等源码逐层印证。读完本文,你将掌握如何在源码层面理解 Zend 的内存分配策略、如何用 USE_ZEND_ALLOC 与 valgrind 定位内存泄漏,以及如何读懂执行器生成脚本 zend_vm_gen.php 的输入输出与各命令行参数,从而有能力修改操作码 handler 并重新生成执行器。
一、文档定位:Zend/README.md 讲了什么
Zend/README.md 是 Zend 引擎目录下的说明文档,聚焦两块 PHP 执行内核最底层的能力:
- Zend memory manager(Zend 内存管理器):自 PHP 5.2 引入,目标是降低内存分配开销、加速内存管理;文档给出了关闭 Zend MM 后用 valgrind 排查泄漏的标准姿势,以及针对共享扩展泄漏的
ZEND_DONT_UNLOAD_MODULES环境变量; - ZEND_VM(操作码虚拟机):允许按操作数的
op_type对操作码 handler 做专用化,支持多种执行线程化模型;文档给出了 handler/helper 的编写模板、新旧执行器宏的完整对照表,以及生成器脚本zend_vm_gen.php的用法。
这两个主题一个管"内存怎么分",一个管"字节码怎么跑",合起来构成了理解 PHP 引擎性能与底层调试的入口。
二、Zend 内存管理器:设计目标与实现结构
2.1 设计目标
按 Zend/README.md 的表述,新内存管理器(自 PHP 5.2 起可用)的目标是减少内存分配开销并加速内存管理。从 Zend/zend_alloc.c 开头的文件头注释看,这套实现(内部代号 zmm/zend_alloc)自述为"现代、CPU 缓存友好的内存管理器",并且多数设计思想借鉴自 jemalloc 与 tcmalloc。
2.2 三类分配:Huge / Large / Small
Zend/zend_alloc.c 头部的注释明确给出分配大小的三段式划分:
| 类别 | 条件 | 分配方式 |
|---|---|---|
| Huge | 大于 CHUNK 大小(默认约 2 MB) | 直接 mmap() 分配,按 2 MB 边界对齐 |
| Large | 一个 CHUNK 内的若干 4096K 页(页对齐块) | 块始终按页边界对齐 |
| Small | 小于 3/4 页大小 | 向上取整到预定义小尺寸档(30 档:8, 16, 24, 32 … 3072 字节),从 RUN 中分配;每个 RUN 是连续的一页或几页,块用空闲元素链表管理,结果按 8 字节对齐 |
其中 CHUNK 的数值在 Zend/zend_alloc_sizes.h 中定义,可以直接核对:
#define ZEND_MM_CHUNK_SIZE ((size_t) (2 * 1024 * 1024)) /* 2 MB */
#define ZEND_MM_PAGES (ZEND_MM_CHUNK_SIZE / ZEND_MM_PAGE_SIZE) /* 512 */
#define ZEND_MM_FIRST_PAGE (1)
也就是说一个 CHUNK 固定 2 MB,默认包含 512 页,第 0 页被保留为管理页——注释说明该页存放"空闲页位图、各小尺寸 RUN 可用位图、记录每页使用情况的页映射表(page map)"等元数据。
2.3 CHUNK 对齐带来的好处
头注释还解释了一个关键设计:zend_alloc 向操作系统按 CHUNK 取内存,CHUNK 与 huge 块都按 CHUNK 边界对齐,因此给定任意指针都能极快反查其所属 CHUNK(对齐后取基址即可)。这一技巧在源码中随处可见,例如 Zend/zend_alloc.c 中的 ZEND_MM_ALIGNED_BASE(ptr, ZEND_MM_CHUNK_SIZE) 系列宏,以及基于 ZEND_MM_ALIGNED_OFFSET 计算页内偏移的逻辑。
2.4 对外 API 与编译期优化
zend_alloc 对外提供常见的 emalloc / efree / erealloc API,此外还提供针对预定义尺寸的专用分配例程(如 emalloc_2()、emalloc_4()、emalloc_large() 等)。库利用 C 预处理技巧:当请求大小在编译期已知时,把对 emalloc() 的调用替换为更高效的专用例程。
2.5 调试:关闭 Zend MM 后用 valgrind 定位泄漏
Zend/README.md 给出的标准调试流程是两条命令。
正常方式(使用 Zend MM,valgrind 无法跟踪其内部分配):
sapi/cli/php -r 'leak();'
关闭 Zend MM 后运行(切回系统 malloc,valgrind 可正确跟踪):
USE_ZEND_ALLOC=0 valgrind --leak-check=full sapi/cli/php -r 'leak();'
从源码看,该开关的读取位于 Zend/zend_alloc.c 的 alloc_globals_ctor():启动时读取环境变量 USE_ZEND_ALLOC,若非零则继续走 Zend 自研堆;设为 0 时则把堆标记为 ZEND_MM_CUSTOM_HEAP_STD,并把底层分配函数换成系统 malloc/free/realloc。同一处还处理了 USE_TRACKED_ALLOC 组合开关:USE_ZEND_ALLOC=0 且 USE_TRACKED_ALLOC=1 时会改用带跟踪记录的 tracked_malloc/tracked_free/tracked_realloc,在进程内记录分配以便自动释放核对。相关测试用例如 Zend/tests/bug40770.phpt、Zend/tests/memory_get_peak_usage.phpt 也都是先读 USE_ZEND_ALLOC 再决定是否跳过,说明"Zend MM 开/关两种模式都要跑通"是该测试套件的基本约定。
除 USE_ZEND_ALLOC 外,同一初始化函数中还处理了两个调试/调优环境变量:
USE_ZEND_ALLOC_HUGE_PAGES:置真时设置zend_mm_use_huge_pages,让堆使用大页(huge pages)分配;ZEND_MM_DEBUG:启用poison_malloc/poison_free等"毒化"调试处理函数,用于检测堆损坏(该能力编译期由ZEND_MM_CUSTOM等开关控制)。
2.6 共享扩展的泄漏排查:ZEND_DONT_UNLOAD_MODULES
Zend/README.md 指出,自 PHP 5.3.11 起,可以防止共享扩展在退出时被卸载,从而使 valgrind 能正确跟踪共享扩展中的内存泄漏——机制是环境变量 ZEND_DONT_UNLOAD_MODULES:设置后,共享扩展关闭阶段的 DL_UNLOAD() 会被跳过。
仓库源码中该变量在两处被检查,可印证文档描述:
- Zend/zend_extensions.c(约 L231):
if (extension->handle && !getenv("ZEND_DONT_UNLOAD_MODULES"))—— 模块清理路径中决定是否调用dlclose; - Zend/zend_API.c(约 L3420):
if (!getenv("ZEND_DONT_UNLOAD_MODULES"))—— API 清理路径中同一判断。
因此针对"共享扩展里的泄漏"这类 valgrind 报不出来的场景,推荐的完整命令形态是:
USE_ZEND_ALLOC=0 ZEND_DONT_UNLOAD_MODULES=1 valgrind --leak-check=full sapi/cli/php -r 'require "ext.so"; leak();'
(其中 ext.so 为待排查的共享扩展,命令形式基于文档中两条命令的组合,扩展名按实际构建产物替换。)
三、ZEND_VM:操作码 handler 专用化与执行模型
3.1 核心思想
Zend/README.md 对 ZEND_VM 的概括是:按操作数的 op_type 字段专用化(specialize)操作码 handler,并支持多种执行方法——调用线程化(call threading)、switch 线程化、直接线程化(direct/ goto threading)。文档原文称:由此 ZE2 在纯 PHP 代码执行上获得了 20% 以上的加速(配合专用化执行器与直接线程化执行方式);同时文档也提醒,多数 PHP 应用的瓶颈在系统调用与数据库调用而非原始执行速度,所以实际收益"因应用而异"。
旧版 zend_execute.c 的大部分代码迁移到了 Zend/zend_vm_def.h,操作码 handler 与 helper 都在这里定义。
3.2 操作码 handler 模板与实际示例
文档给出的标准模板:
ZEND_VM_HANDLER(<OPCODE-NUMBER>, <OPCODE>, <OP1_TYPES>, <OP2_TYPES>)
{
<HANDLER'S CODE>
}
参数含义(按文档原文继承):
<OPCODE-NUMBER>:操作码编号(0, 1, …);<OPCODE>:操作码名称(如ZEND_NOP、ZEND_ADD);<OP1_TYPES>/<OP2_TYPES>:允许的操作数op_type掩码,可组合UNUSED、CONST、VAR、TMP、CV,也可用ANY掩码禁用对该操作数的专用化;专用化器只为掩码中定义的类型组合生成代码;<HANDLER'S CODE>:handler 本体,多数与旧版zend_execute.c相同,但改用宏访问操作数与执行器内部数据。
Zend/zend_vm_def.h 中的真实 handler 与模板完全一致,例如:
ZEND_VM_HANDLER(8, ZEND_CONCAT, CONST|TMP|CV, CONST|TMP|CV, SPEC(NO_CONST_CONST))
ZEND_VM_HANDLER(22, ZEND_ASSIGN, VAR|CV, CONST|TMP|CV, SPEC(RETVAL))
ZEND_VM_HANDLER(80, ZEND_FETCH_R, CONST|TMP|CV, UNUSED, VAR_FETCH)
可以看到掩码写法(CONST|TMP|CV、VAR|UNUSED|THIS|CV 等)与文档描述吻合,且当前代码还带有第三类扩展位(VAR_FETCH、CACHE_SLOT、SPEC(...) 等),用于向生成脚本声明操作数的扩展语义——这些扩展标志的编码表就定义在 Zend/zend_vm_gen.php 顶部的 $vm_ext_decode / $vm_op_flags 中。
3.3 新旧执行器宏对照表(文档原文完整继承)
Zend/README.md 给出了新宏与旧代码的一一对照,是迁移/阅读执行器代码的关键速查表:
| 新宏 | 对应的旧代码 |
|---|---|
EXECUTE_DATA |
execute_data |
ZEND_VM_DISPATCH_TO_HANDLER(<OP>) |
return <OP>_helper(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU) |
ZEND_VM_DISPATCH_TO_HELPER(<NAME>) |
return <NAME>(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU) |
ZEND_VM_DISPATCH_TO_HELPER_EX(<NAME>,<PARAM>,<VAL>) |
return <NAME>(<VAL>, ZEND_OPCODE_HANDLER_ARGS_PASSTHRU) |
ZEND_VM_CONTINUE() |
return 0 |
ZEND_VM_NEXT_OPCODE() |
NEXT_OPCODE() |
ZEND_VM_SET_OPCODE(<TARGET>) |
SET_OPCODE(<TARGET>) |
ZEND_VM_INC_OPCODE() |
INC_OPCOD() |
ZEND_VM_RETURN_FROM_EXECUTE_LOOP() |
RETURN_FROM_EXECUTE_LOOP() |
ZEND_VM_C_LABEL(<LABEL>): |
<LABEL>: |
ZEND_VM_C_GOTO(<LABEL>) |
goto <LABEL> |
OP<X>_TYPE |
opline->op<X>.op_type |
GET_OP<X>_ZVAL_PTR(<TYPE>) |
get_zval_ptr(&opline->op<X>, EX(Ts), &free_op<X>, <TYPE>) |
GET_OP<X>_ZVAL_PTR_PTR(<TYPE>) |
get_zval_ptr_ptr(&opline->op<X>, EX(Ts), &free_op<X>, <TYPE>) |
GET_OP<X>_OBJ_ZVAL_PTR(<TYPE>) |
get_obj_zval_ptr(&opline->op<X>, EX(Ts), &free_op<X>, <TYPE>) |
GET_OP<X>_OBJ_ZVAL_PTR_PTR(<TYPE>) |
get_obj_zval_ptr_ptr(&opline->op<X>, EX(Ts), &free_op<X>, <TYPE>) |
IS_OP<X>_TMP_FREE() |
IS_TMP_FREE(free_op<X>) |
FREE_OP<X>() |
FREE_OP(free_op<X>) |
FREE_OP<X>_IF_VAR() |
FREE_VAR(free_op<X>) |
FREE_OP<X>_VAR_PTR() |
FREE_VAR_PTR(free_op<X>) |
3.4 Executor helpers
文档说明执行器 helper 可以无参或带一个参数,分别用如下构造定义:
ZEND_VM_HELPER(<HELPER-NAME>, <OP1_TYPES>, <OP2_TYPES>)
{
<HELPER'S CODE>
}
ZEND_VM_HELPER_EX(<HELPER-NAME>, <OP1_TYPES>, <OP2_TYPES>, <PARAM_SPEC>)
{
<HELPER'S CODE>
}
handler 通过 ZEND_VM_DISPATCH_TO_HELPER(<NAME>) 把"慢路径"(如需要错误处理、类型不匹配时的兜底逻辑)分派到 helper,而专用化生成的快路径里只保留最常见的类型组合。
四、执行器生成器:zend_vm_gen.php
4.1 输入、输出与包含关系
Zend/README.md 明确了生成流程:
- 输入:Zend/zend_vm_def.h(handler/helper 定义)+ Zend/zend_vm_execute.skl(执行循环骨架);
- 输出:
Zend/zend_vm_opcodes.h(操作码定义清单,被 Zend/zend_compile.h 包含)与Zend/zend_vm_execute.h(执行器代码本体,被 Zend/zend_execute.c 包含)。
Zend/zend_vm_gen.php 文件头的注释同样写明:"This script creates zend_vm_execute.h and zend_vm_opcodes.{h,c} from existing zend_vm_def.h and zend_vm_execute.skl",与文档一致。
4.2 命令行参数
文档列出的三个开关在 Zend/zend_vm_gen.php 的参数解析代码中均可对应(约 L3268-L3299):
--with-vm-kind=CALL|SWITCH|GOTO|HYBRID:选择操作码线程化模型,脚本帮助文本标注默认值为 HYBRID;--without-specializer:禁用操作码专用化(生成的 handler 不区分 op_type 组合);--with-lines:启用#line指令,便于调试时直接定位到原始的zend_vm_def.h行号(文档说明用原始定义文件调试执行器需要该选项)。
文档给出的默认生成命令:
# Default VM kind is HYBRID
php zend_vm_gen.php --with-vm-kind=HYBRID
从 Zend/zend_vm_gen.php 源码结构看,脚本内还定义了第五种常量 ZEND_VM_KIND_TAILCALL = 5(与 CALL=1、SWITCH=2、GOTO=3、HYBRID=4 并列于 $vm_kind_name 表),即当前仓库的执行器生成器在文档所列四种模型之外还实现了 tail-call 模型,但文档正文与帮助文本仍以 CALL|SWITCH|GOTO|HYBRID 作为面向使用者的选项集;以实际参数解析为准选用。
4.3 各线程化模型的差别(结合源码结构)
从 Zend/zend_vm_gen.php 的骨架生成逻辑可以推断四种模型在代码形态上的差异:
- CALL threading:每条 opline 对应一次函数调用
case <opcode>:后调用 handler,依赖编译器内联; - SWITCH threading:一个大
switch(opcode)循环体; - GOTO(direct)threading:用
goto <opcode>_case:标签直接跳转,避免分支预测开销; - HYBRID:混合策略,对热点/长路径用直接跳转,其余退回 switch 结构,是仓库默认(
--with-vm-kind=HYBRID),兼顾代码体积与跳转距离(x86jmp有 ±2GB 位移限制,巨型 switch 可能超出编码范围,混合模型可缓解这一问题——此点属于从生成器结构出发的合理推断)。
无论选哪种模型,专用化(specializer)都会为每个 handler 的每个允许 op_type 组合展开生成独立函数体,这也是文档强调"只为定义的类型组合生成代码"的原因:类型组合越少,展开后的代码越紧凑、分支预测越友好。
五、修改执行器后的完整工作流
综合 Zend/README.md 与源码,一次典型的"改操作码 handler"工作流是:
- 在 Zend/zend_vm_def.h 中修改或新增
ZEND_VM_HANDLER(...)/ZEND_VM_HELPER(...)定义(可参考ZEND_VM_HANDLER(22, ZEND_ASSIGN, VAR|CV, CONST|TMP|CV, SPEC(RETVAL))等现有写法); - 执行生成脚本(默认):
php Zend/zend_vm_gen.php --with-vm-kind=HYBRID
- 得到新的
Zend/zend_vm_execute.h与Zend/zend_vm_opcodes.h,重新编译即可; - 调试生成产物时用
--with-lines重新生成,使zend_vm_execute.h携带#line指令,断点可直接映射回zend_vm_def.h的源码行。
六、延伸阅读入口
| 主题 | 文件 |
|---|---|
| 本文对应的原始文档 | Zend/README.md |
| 内存管理器实现(zmm) | Zend/zend_alloc.c、Zend/zend_alloc.h、Zend/zend_alloc_sizes.h |
| 共享扩展卸载开关 | Zend/zend_extensions.c、Zend/zend_API.c |
| 操作码 handler 定义 | Zend/zend_vm_def.h |
| 执行循环骨架与生成器 | Zend/zend_vm_execute.skl、Zend/zend_vm_gen.php |
| 执行器入口 | Zend/zend_execute.c |
| 依赖 Zend MM 行为的测试示例 | Zend/tests/gh11189.phpt、Zend/tests/new_oom.phpt |
需要强调的适用前提:本文所有结论以当前仓库快照为准;USE_ZEND_ALLOC、USE_ZEND_ALLOC_HUGE_PAGES、ZEND_DONT_UNLOAD_MODULES 等均为运行时环境变量,--with-vm-kind 等参数仅影响本地生成的执行器文件,不会改动源码行为本身;文档中"20% 以上加速"的表述引自项目自带文档的历史结论,具体性能收益仍取决于应用负载(系统调用、数据库访问往往才是瓶颈)。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00