首页
/ Protobuf PHP 扩展实战:zval 与引用计数(Refcounting)正确姿势全解析

Protobuf PHP 扩展实战:zval 与引用计数(Refcounting)正确姿势全解析

2026-09-06 11:36:22作者:尤峻淳Whitney

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);

由此得到两条可操作的判定规则,也是文档中最重要的两条"经验法则":

  1. 看到 zval(值类型):可以假定它拥有一个引用(或存的是非引用计数类型);
  2. 看到 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.cdef.carray.cmap.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_cachezend_hash_*_ptr 系列 API 存裸指针zend_object*),upb 对象指针 -> PHP 对象指针 的映射不参与引用计数(ObjCache_Addzend_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);
}

这里 tmpcreate_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.carray.c
持有 zval*(不拥有引用)时返回 RETURN_COPY(zv) 加 1 引用后返回 def.c getPublicDescriptorconvert.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 展示了"存入析构型容器前必须补引用";getPublicDescriptorRETURN_COPY(getThis()) 展示了"裸指针返回值的安全姿势"。在扩展开发中,每写一处涉及 zval 的代码前,先回答"这份引用归谁、何时归还",就能避开绝大多数泄漏与 double-free 问题。

登录后查看全文
热门项目推荐
相关项目推荐