Protobuf PHP 扩展实战:zval 与引用计数(Refcounting)正确姿势全解析
PHP 的 C 扩展开发中,最容易踩坑、也最影响稳定性的部分之一就是引用计数(refcounting):少释放一次是内存泄漏,多释放一次是 double-free 崩溃。本文以 Protocol Buffers 仓库中 php/REFCOUNTING.md 这份《Refcounting Tips》为核心,完整继承其关于 zval、ZVAL_* 宏族、引用转移的全部要点,并结合 protobuf PHP 扩展(php/ext/ 目录)中的真实源码——Descriptor 注册、对象缓存、字段读取等场景——把每一条原则落到可验证的实现证据上。读完本篇,你能掌握在 Zend Engine 中"谁拥有引用、何时该加引用、何时该交还引用"的判定方法,并能看懂 protobuf PHP 扩展里几乎所有 zval 相关代码段的内存语义。
一、zval:PHP 变量的 C 层对应物
文档首先明确了 zval 的定位:它是 PHP 变量的 C 层近似物。在 PHP 里写:
// Think of $a as a "zval".
$a = [];
对应的 C 代码是:
zval a;
ZVAL_NEW_ARR(&a); // 分配并赋值一个新数组。
关键前提是:PHP 是引用计数语言。每个变量——也就是每个 zval——都持有其所指向对象的一个引用(除非存的是完全不参与引用计数的类型,如整数等标量)。既然 zval 拥有一个引用,它就必须在生命周期结束时显式销毁,以释放这个引用:
zval a;
ZVAL_NEW_ARR(&a);
// zval 的析构函数,不调用就会泄漏这个引用。
zval_ptr_dtor(&a);
由此得到两条可操作的判定规则,也是文档中最重要的两条"经验法则":
- 看到
zval(值类型):可以假定它拥有一个引用(或存的是非引用计数类型); - 看到
zval*(指针):它只是指向一个拥有引用的结构,自身并不拥有引用。
这个区分是整个扩展代码的语义基础。比如 protobuf 扩展中 Message 结构体(php/ext/google/protobuf/message.c)就同时展示了两种形态:
typedef struct {
zend_object std;
zval arena; // 值类型:Message 拥有 arena 对象的一个引用
const Descriptor* desc;
upb_Message* msg;
} Message;
zval arena 是值类型,所以 Message_dtor() 里必须显式释放它(message.c):
static void Message_dtor(zend_object* obj) {
Message* intern = (Message*)obj;
ObjCache_Delete(intern->msg);
zval_ptr_dtor(&intern->arena); // 释放 Message 拥有的那份引用
zend_object_std_dtor(&intern->std);
}
而像 Message_get(Message* intern, const upb_FieldDef* f, zval* rv) 这样的函数签名,参数 zval* rv 只是"指向调用者拥有的 zval",函数负责填充它、把所有权交给调用方,自身不额外持有引用。
二、ZVAL_* 宏族:初始化 zval 的两种方式
文档给出了 ZVAL_* 宏族(定义于 PHP 源码的 Zend/zend_types.h)的几个典型成员:
| 宏 | 含义 | 是否引用计数 |
|---|---|---|
ZVAL_NULL(&zv) |
初始化为 null |
否 |
ZVAL_LONG(&zv, 5) |
初始化 zend_long 整数 |
否 |
ZVAL_ARR(&zv, arr) |
初始化 zend_array* |
是 |
ZVAL_OBJ(&zv, obj) |
初始化 zend_object* |
是 |
文档特别强调:protobuf 扩展里的所有自定义对象(消息、repeated 字段、descriptor 等)都是 zend_object* 类型,因此都落入"引用计数"这一类。
2.1 ZVAL_OBJ:不增加引用,适合接住"刚创建的对象"
从引用计数类型初始化的 ZVAL_* 变体不会增加 refcount。这个特性让它们特别适合用来接收一个全新创建、引用计数恰好为 1 的对象——所有权从创建者平滑转移给这个 zval。文档示例:
zval zv;
ZVAL_OBJ(&zv, CreateObject());
protobuf 扩展中的 def.c 正是这样用的:
static void EnumValueDescriptor_Make(zval* val, const char* name,
int32_t number) {
EnumValueDescriptor* intern = emalloc(sizeof(EnumValueDescriptor));
zend_object_std_init(&intern->std, EnumValueDescriptor_class_entry);
intern->std.handlers = &EnumValueDescriptor_object_handlers;
intern->name = name;
intern->number = number;
// 跳过 object_properties_init(),因为不允许派生类。
ZVAL_OBJ(val, &intern->std); // 不增加引用:所有权移交给调用者的 val
}
刚 emalloc 出来的对象 refcount 为 1,ZVAL_OBJ 把这份所有权交给 val,不多不少。
2.2 ZVAL_OBJ_COPY:同时增加引用,适合"共享已有对象"
当需要一个额外的引用(比如把全局对象再次暴露出去、把对象存进缓存),则用 ZVAL_OBJ_COPY()。文档示例:
zend_object *some_global;
void GetGlobal(zval *zv) {
// 我们想为一个已存在的对象创建一份新引用。
ZVAL_OBJ_COPY(zv, some_global);
}
在 protobuf 扩展里这是真实存在的模式,而且源码注释把"为什么"讲得很直白——php/ext/google/protobuf/protobuf.c:
void Descriptors_Add(zend_object* desc) {
// 哈希表将拥有一份引用(表销毁时会析构),但 insert 操作本身不加引用,
// 所以我们在这里用 ZVAL_OBJ_COPY() 显式加上。
zval zv;
ZVAL_OBJ_COPY(&zv, desc);
zend_hash_next_index_insert(&PROTOBUF_G(descriptors), &zv);
}
这里的语义链条值得细看:zend_hash_next_index_insert 插入的 zval 归哈希表所有,表销毁时会对其调用析构(见下面 RINIT 代码中的 ZVAL_PTR_DTOR 析构器),所以插入前必须先"多要一份引用",否则表析构时会把别人还在用的对象提前销毁。
同样模式也出现在对象缓存的读取路径 ObjCache_Get:从缓存(一个 zend_object* 指针表)里找到对象后,用 ZVAL_OBJ_COPY(val, obj) 把一份新引用交付给调用者——缓存本身只存裸指针、不拥有引用,调用者拿走的才是被计数的引用。
三、转移引用:RETURN_COPY_VALUE 与 RETURN_COPY
文档第三部分讨论"引用的最终去向":zval_ptr_dtor() 虽然是最简单的释放方式,但在实际代码库中并不最常见——更常见的是把 zval 归还给 PHP。
3.1 手里有完整 zval:用 RETURN_COPY_VALUE
zval zv;
InitializeOurZval(&zv);
// 把 zv 的值返回给调用方,并把我们的引用"捐"出去。
RETURN_COPY_VALUE(&zv);
RETURN_COPY_VALUE()(PHP 8.x 原生提供,PHP 8 之前的版本需自行 polyfill)是这个扩展最主流的返回方式,因为它把我们 zval 的引用权移交给调用方,省去了显式销毁本地 zval 的负担。扩展代码中它被大量使用,例如 message.c、def.c、array.c、map.c 中的字段读取与 descriptor 获取路径,模式统一为:
zval ret;
/* ... 填充 ret,此时 ret 拥有自己的引用 ... */
RETURN_COPY_VALUE(&ret); // 所有权移交给 PHP 调用方,C 侧无需再 dtor
3.2 手里只有 zval*:用 RETURN_COPY
解析函数参数时常常拿到的是 zval* 而非 zval:向 zend_parse_parameters 请求一个 "z" 参数时,PHP 给的是已有 zval 结构的指针,不创建新结构,因此我们对它并不拥有引用。此时用 RETURN_COPY:
zval *val;
if (zend_parse_parameters(ZEND_NUM_ARGS(), "z", &val) == FAILURE) {
return;
}
// 返回该 zval 的一份拷贝,并在此过程中加一次引用。
RETURN_COPY(val);
RETURN_COPY 会增加 refcount,正适合"我手上只有指针、不拥有引用"的场景:加一次引用、把新引用交给返回值,原 zval 不受影响。
一个典型的 RETURN_COPY 用法是把 this 对象返回出去(如 def.c):
PHP_METHOD(EnumDescriptor, getPublicDescriptor) { RETURN_COPY(getThis()); }
PHP_METHOD(Descriptor, getPublicDescriptor) { RETURN_COPY(getThis()); }
getThis() 返回的是指向当前对象 zval 的指针,扩展并不拥有它,RETURN_COPY 恰好在返回前加一份引用,PHP 侧拿到的返回值就是独立持有引用、生命周期安全的。convert.c 中解析参数后 RETURN_COPY(val) 的用法与之同理。
四、在 protobuf 扩展中串联这些原则
把文档的三条原则——"zval 拥有引用需显式 dtor"、"ZVAL_OBJ 不加引用 / ZVAL_OBJ_COPY 加引用"、"zval* 需 RETURN_COPY"——放到扩展的实际生命周期中,可以得到一份可对照的完整图景。
4.1 请求级生命周期:谁在 RINIT/RSHUTDOWN 里清理
php/ext/google/protobuf/protobuf.c 的模块生命周期函数展示了"容器拥有引用"的另一半逻辑:
static PHP_RINIT_FUNCTION(protobuf) {
/* ... 创建全局符号表与名称缓存 ... */
zend_hash_init(&PROTOBUF_G(object_cache), 64, NULL, NULL, 0);
zend_hash_init(&PROTOBUF_G(descriptors), 64, NULL, ZVAL_PTR_DTOR, 0);
/* 注意:descriptors 表注册了 ZVAL_PTR_DTOR 析构器——
表销毁时会对每个元素 zval 执行 ptr_dtor,
这正是 Descriptors_Add 必须 ZVAL_OBJ_COPY 的原因 */
return SUCCESS;
}
static PHP_RSHUTDOWN_FUNCTION(protobuf) {
if (!PROTOBUF_G(keep_descriptor_pool_after_request)) {
free_protobuf_globals(ZEND_MODULE_GLOBALS_BULK(protobuf));
}
zend_hash_destroy(&PROTOBUF_G(object_cache));
zend_hash_destroy(&PROTOBUF_G(descriptors));
return SUCCESS;
}
注意两个表的区别:
object_cache用zend_hash_*_ptr系列 API 存裸指针(zend_object*),upb 对象指针 -> PHP 对象指针的映射不参与引用计数(ObjCache_Add 中zend_hash_index_add_ptr不增引用),所以销毁表不会释放任何 zval;descriptors表存 zval 且注册了ZVAL_PTR_DTOR,销毁表即逐个释放引用——这正是"插入前必须ZVAL_OBJ_COPY"的闭环。
4.2 临时对象:用完必须双释放
NameMap_GetMessage 展示了"临时创建对象、用完立刻释放"的标准写法,两条原则同时出现:
if (!ret && ce->create_object && ce != PROTOBUF_G(constructing_class)) {
zval zv;
zend_object* tmp = ce->create_object(ce);
zend_call_method_with_0_params(tmp, ce, NULL, "__construct", &zv);
OBJ_RELEASE(tmp); // 释放 C 侧对 zend_object 的持有引用
zval_ptr_dtor(&zv); // 释放 zval 持有的引用(来自 __construct 返回值)
ret = zend_hash_find_ptr(&PROTOBUF_G(name_msg_cache), ce->name);
}
这里 tmp 是 create_object 产生的对象引用,zv 是 __construct 调用返回的 zval(它拥有自己的引用)——两者各释放一次,一次不多一次不少,是"看到 zval 就假定它拥有引用"这一规则的直接体现。
4.3 属性读取路径:zval* 出参的所有权约定
Message_get 展示了扩展内部统一的出参约定:函数接收 zval* rv(不拥有引用),负责填充内容并把所有权移交给调用者:
- map/repeated 字段:调用
MapField_GetPhpWrapper(rv, ...)/RepeatedField_GetPhpWrapper(rv, ...)生成 PHP 侧包装对象; - 未设置的子消息字段:
ZVAL_NULL(rv)——ZVAL_NULL不涉引用计数,天然安全。
这与文档"zval* 指向拥有引用的结构"完全对应:被填充的 rv 位于调用栈上的某个"拥有引用"的 zval 里,函数只是替它初始化内容,因此不会引入额外的引用计数负担。
五、速查表:常见操作与引用语义
综合原文档与扩展源码,可以整理成一张日常开发速查表:
| 场景 | 推荐写法 | 引用语义 | 扩展中的实例 |
|---|---|---|---|
| 新建对象并初始化 zval | ZVAL_OBJ(&zv, obj) |
不加引用,接住所有权 | def.c EnumValueDescriptor_Make |
| 为已有对象再要一份引用 | ZVAL_OBJ_COPY(&zv, obj) |
加 1 引用 | protobuf.c Descriptors_Add |
| 本地持有完整 zval 时返回 | RETURN_COPY_VALUE(&zv) |
移交引用,无需 dtor | message.c、array.c |
| 持有 zval*(不拥有引用)时返回 | RETURN_COPY(zv) |
加 1 引用后返回 | def.c getPublicDescriptor、convert.c |
结构体内嵌 zval 成员析构 |
zval_ptr_dtor(&member) |
释放该成员引用 | message.c Message_dtor |
| 临时对象用完即弃 | OBJ_RELEASE(obj) + zval_ptr_dtor(&zv) |
两条引用各释放一次 | protobuf.c NameMap_GetMessage |
六、小结
php/REFCOUNTING.md 的核心贡献,是把 Zend Engine 引用计数的三条底层规则讲成了可执行的心法:zval 值拥有引用(要 dtor)、ZVAL_*(非 COPY)不加引用而 ZVAL_*_COPY 加引用、zval* 不拥有引用(返回用 RETURN_COPY)。protobuf 的 PHP 扩展(php/ext/google/protobuf/)则提供了这套心法的完整工程注脚:Message 结构体的 zval arena 成员展示了"值成员必须在 dtor 中释放";Descriptors_Add 展示了"存入析构型容器前必须补引用";getPublicDescriptor 的 RETURN_COPY(getThis()) 展示了"裸指针返回值的安全姿势"。在扩展开发中,每写一处涉及 zval 的代码前,先回答"这份引用归谁、何时归还",就能避开绝大多数泄漏与 double-free 问题。
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 StartedRust0624
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