内存管理
为了正确高效地使用 zval
,了解其内存管理的工作原理非常重要。广义上讲,我们可以将值分为两类:简单值(如整数)直接存储在 zval
中;复杂值(如字符串)zval
仅存储指向单独结构的指针。
引用计数值
所有复杂值共享一个具有以下结构的公共标头:
typedef struct _zend_refcounted_h {
uint32_t refcount;
union {
uint32_t type_info;
} u;
} zend_refcounted_h;
此标头存储引用计数,用于跟踪此结构在多少地方被使用。如果在新的 zval
中使用该结构,则引用计数会增加。如果不再使用,则引用计数会减少。如果引用计数达到零,我们就知道该结构不再使用,可以释放。这是 PHP 内存管理的核心机制。
type_info
字段编码了其他信息,例如结构的类型、多个类型特定标志以及垃圾收集根。我们稍后将讨论这些信息的用途。
有用于创建不同类型的引用计数结构的函数,这些函数将使用初始引用计数 1 来创建它们:
zend_string *str = zend_string_init("test", sizeof("test")-1, /* persistent */ 0); // refcount=1
zend_array *arr = zend_new_array(/* size hint */ 0); // refcount=1
// Do something with str and arr.
zend_string_release(str); // refcount=0 => destroy!
zend_array_release(arr); // refcount=0 => destroy!
zend_string_release() 和 zend_array_release() 函数将减少字符串或数组的引用计数,如果计数达到零,则销毁它。例如,以下代码完全有效:
zend_string *str = zend_string_init("test", sizeof("test")-1, /* persistent */ 0); // refcount=1
zend_hash_add_empty_element(arr, str); // refcount=2
zend_string_release(str); // refcount=1
这将向数组添加一个带有键 str 的元素,然后释放该字符串。但是,zend_hash_add_empty_element() 函数将增加字符串的引用计数,因此 zend_string_release() 调用不会销毁它。只有当数组也被销毁并且没有对字符串的引用时,它才会被销毁。
不可变值
虽然所有复杂结构都共享 zend_refcounted_h
标头,但引用计数并不总是实际使用的。字符串和数组可以是不可变的,这意味着整个结构(包括引用计数)绝不能被修改。此类结构可以在不增加引用计数的情况下重复使用,并且保证在(至少)请求结束之前不会被销毁。
存在不可变字符串和数组的原因有很多:
-
存储在 opcache 共享内存中的任何结构都是不可变的,因为它们在多个进程之间共享。您可以设置 opcache.protect_memory=1 ini 设置,以便通过 mprotect() 强制执行此操作。这将使大多数不可变性违规导致崩溃而不是不当行为。
-
空数组被声明为 const,因此通常分配在只读段中。尝试修改它将导致崩溃。
-
在请求之外创建但可能在请求内部使用的持久字符串(例如 ini 值)必须是不可变的,因为可能有多个线程并行使用它们。由于 PHP 的引用计数是非原子的,因此执行正常的引用计数并不安全。
-
最后,虽然上述原因使不可变结构成为技术要求,但拥有它们也可以作为性能优化,因为在许多常见情况下可以避免引用计数操作。
使用 zend_string_copy() 或 ZVAL_COPY() 等高级 API 时,不可变结构将自动得到正确处理。但是,如果您使用低级 API,则需要明确考虑它们。
低级接口主要通过以下宏提供:
宏 | 描述 |
---|---|
GC_TYPE |
获取结构的类型(IS* 常量)。 |
GC_FLAGS |
取得标志。 |
GC_REFCOUNT |
获取引用计数。 |
GC_ADDREF |
增加引用计数。结构必须是可变的。 |
GC_DELREF |
减少引用计数。结构必须是可变的。如果引用计数达到零,则不会释放结构。 |
GC_TRY_ADDREF |
如果可变则增加引用计数,否则不执行任何操作。 |
不可变结构设置 GC_IMMUTABLE 标志(它有许多别名,如 IS_STR_INTERNED 和 IS_ARRAY_IMMUTABLE),可用于确定增加引用计数是否安全:
zend_string *str = /* ... */;
if (!(GC_FLAGS(str) & GC_IMMUTABLE)) {
GC_ADDREF(str);
}
// Same as:
GC_TRY_ADDREF(str);
// Same as (high-level API):
zend_string_addref(str);
名称中包含 TRY 的宏通常表示操作仅应针对可变结构执行。您会遇到更多示例,例如 Z_TRY_ADDREF 和 GC_TRY_PROTECT_RECURSION,它们的含义相同。
持久的结构
PHP 使用两个分配器:每个请求分配器(在请求结束时释放所有内存)和持久分配器(在多个请求之间保留分配)。持久分配器实际上与普通系统分配器相同。有关 PHP 分配管理的更多信息,请参阅 PHP 生命周期 和 Zend 内存管理器 章节。
许多创建引用计数结构的函数将接受持久标志来确定使用哪个分配器。一个例子是 zend_string_init() 的最后一个参数。如果函数没有公开持久标志,那么一个好的默认假设是使用每个请求(非持久)分配器。例如,zend_array_new() 函数始终创建一个每个请求数组,而必须使用较低级别的 API 来创建持久数组。
持久结构设置 GC_PERSISTENT 标志,它们的析构函数将自动使用正确的分配器来释放内存。因此,除了首先使用正确的分配器(通常是每个请求的分配器)之外,您通常不需要担心此标志。
但是,了解持久结构如何与请求期间执行的代码交互非常重要:持久结构可能被多个线程使用。由于 PHP 的引用计数是非原子的,因此从多个线程执行引用计数会导致数据争用(这将导致崩溃)。
因此,在请求期间使用的任何持久结构都必须是不可变的或线程本地的。可以使用 CFLAGS="-DZEND_RC_DEBUG=1" 编译 PHP 以自动诊断此类问题。此问题最常影响字符串,在这种情况下,可以通过驻留使它们不可变。GC_MAKE_PERSISTENT_LOCAL() 宏用于将持久结构标记为线程本地。除了禁用 ZEND_RC_DEBUG 验证之外,此宏不会执行任何操作。
Zval内存管理
有了这些准备工作,我们可以讨论内存管理如何与 zval 交互。引用计数结构可以独立使用,但将它们存储在 zval 中无疑是更常见的用例之一。
Zval 本身永远不会单独进行堆分配。它们要么临时分配在堆栈上,要么嵌入为更大的堆分配结构的一部分。
这个基本示例展示了堆栈分配的 zval 的初始化及其随后的销毁:
zval str_val;
ZVAL_STRING(&str_val, "foo"); // Creates zend_string (refcount=1).
// ... Do something with str_val.
zval_ptr_dtor(&str_val); // Decrements to refcount=0, and destroys the string.
ZVAL_STRING() 创建一个字符串 zval,zval_ptr_dtor() 释放它。稍后我们将讨论不同的初始化宏和析构函数。
堆栈分配的 zval 只能在声明它的范围内使用。虽然从技术上讲可以返回 zval,但您会发现 PHP 从不通过值传递或返回 zval。相反,zval 总是通过指针传递。为了返回 zval,需要将一个 out 参数传递给函数:
// retval is an output parameter.
void init_zval(zval *retval) {
ZVAL_STRING(retval, "foo");
}
void some_other_function() {
zval val;
init_zval(&val);
// ... Do something with val.
zval_ptr_dtor(&val);
}
虽然 zval 本身通常不共享,但可以使用引用计数机制共享它们指向的结构。Z_REFCOUNT、Z_ADDREF 和 Z_DELREF 宏的工作方式与相应的 GC_* 宏相同,但操作的是 zval。重要的是,这些宏只能在 zval 指向引用计数结构且该结构不是不可变时使用。IS_TYPE_REFCOUNTED 类型标志确定是否是这种情况,并且可以通过 Z_REFCOUNTED 访问:
void fill_array(zval *array) {
zval val;
init_zval(&val);
// Manually check REFCOUNTED:
if (Z_REFCOUNTED(val)) {
Z_ADDREF(val);
}
add_index_zval(array, 0, &val);
// Or use the TRY macro:
Z_TRY_ADDREF(val);
add_index_zval(array, 1, &val);
zval_ptr_dtor(&val);
}
此示例将相同的值添加到数组两次,这意味着引用计数必须增加两次。虽然可以手动检查 zval 是否为 Z_REFCOUNTED,但最好使用 Z_TRY_ADDREF,它只会增加引用计数结构的引用计数。
这里需要考虑的是谁负责增加引用计数。在此示例中,add_index_zval() 的调用者负责增加。不幸的是,PHP API 在这方面不太一致。作为一个非常粗略的经验法则,数组值期望引用计数由调用者增加,而大多数其他 API 会自行处理。
复制 zvals
通常情况下,zval 需要从一个位置复制到另一个位置。为此,提供了许多复制宏。第一个是 ZVAL_COPY_VALUE():
void init_zval_indirect(zval *retval) {
zval val;
init_zval(&val);
ZVAL_COPY_VALUE(retval, &val);
}
这个(相当愚蠢的)示例初始化堆栈 zval,然后将值移到 retval 输出参数中。ZVAL_COPY_VALUE 宏执行简单的 zval 复制而不增加引用计数。因此,它的主要用途是移动 zval,这意味着原始 zval 将不再使用(包括它不应被销毁)。有时,此宏还用作优化来复制我们知道不会被引用计数的 zval。
ZVAL_COPY_VALUE 宏与简单赋值(*retval = val)的不同之处在于它只复制 zval 值和类型,但不复制其 u2 成员。因此,将 ZVAL_COPY_VALUE 放入正在使用的 u2 成员的 zval 中是安全的,因为它不会被覆盖。
第二个宏是 ZVAL_COPY,它是 ZVAL_COPY_VALUE 和 Z_TRY_ADDREF 的优化组合:
void init_pair(zval *retval1, zval *retval2) {
zval val;
init_zval(&val); // refcount=1
ZVAL_COPY(retval1, &val); // refcount=2
ZVAL_COPY(retval2, &val); // refcount=3
zval_ptr_dtor(&val); // refcount=2
}
此示例复制了两次值,并增加了两次引用计数(如果有)。编写此函数的另一种略微更有效的方法是:
void init_pair(zval *retval1, zval *retval2) {
zval val;
init_zval(&val); // refcount=1
ZVAL_COPY(retval1, &val); // refcount=2
ZVAL_COPY_VALUE(retval2, &val); // refcount=2
}
这会将值复制到 retval1 中一次,然后执行移动到 retval2 的操作,从而节省了多余的引用计数递增和递减。最后,我们在实践中可能会这样编写此代码:
void init_pair(zval *retval1, zval *retval2) {
init_zval(retval1); // refcount=1
ZVAL_COPY(retval2, retval1); // refcount=2
}
在这里,值直接初始化到 retval1 中,然后复制到 retval2 中。这个版本最简单,效率最高。
ZVAL_DUP 宏与 ZVAL_COPY 类似,但会复制数组,而不仅仅是增加它们的引用计数。如果您使用此宏,那么您几乎肯定会做错事。
最后,ZVAL_COPY_OR_DUP 是一个非常专业的复制宏,可用于在请求期间从潜在持久的 zval 复制。如前所述,在这种情况下增加引用计数是非法的,因为它不是线程安全的。此宏将增加非持久值的引用计数,但对持久值执行完整的字符串/数组复制。
销毁 zvals
上述示例已经利用 zval_ptr_dtor() 来销毁 zval。如果值被引用计数,则此函数会减少引用计数并在其达到零时销毁该值。
但是,这里有一个微妙之处:引用计数不足以检测属于循环一部分的未使用值。因此,PHP 采用了额外的标记和清除式循环垃圾收集器 (GC)。当引用计数减少但未达到零,并且结构被标记为潜在循环(未设置 GC_NOT_COLLECTABLE 标志)时,PHP 会将该结构添加到 GC 根缓冲区。
zval_ptr_dtor_nogc() 函数是一种不执行 GC 根缓冲区检查的变体,并且只有在您知道被销毁的数据是非循环的情况下才可以安全使用。zval_dtor() 是同一函数的旧别名。
内部代码中可能遇到的另一种变体是 i_zval_ptr_dtor(),它与 zval_ptr_dtor() 相同,但使用内联实现。i_ 前缀是具有内联和外联变体的函数的一般约定。
初始化 zvals
到目前为止,我们一直在使用抽象的 init_zval() 函数来初始化 zval。PHP 使用大量宏来处理 zval 初始化,这并不令人意外。简单类型的初始化尤其简单:
zval val;
ZVAL_UNDEF(&val);
zval val;
ZVAL_NULL(&val);
zval val;
ZVAL_FALSE(&val);
zval val;
ZVAL_TRUE(&val);
zval val;
ZVAL_BOOL(&val, zero_or_one);
zval val;
ZVAL_LONG(&val, 42);
zval val;
ZVAL_DOUBLE(&val, 3.141);
对于字符串,有很多初始化选项。最基本的是 ZVAL_STR() 宏,它采用已构造的 zend_string*:
zval val;
ZVAL_STR(&val, zend_string_init("test", sizeof("test")-1, 0));
由于从字符串字面值或现有字符串创建 zend_string 非常常见,因此有两个便捷包装器:
zval val;
ZVAL_STRINGL(&val, "test", sizeof("test")-1);
zval val;
ZVAL_STRING(&val, "test"); // Uses strlen() for length.
ZVAL_STR 宏会根据字符串是否不可变来设置 IS_TYPE_REFCOUNTED 标志。如果事先知道字符串是否被驻留,可以有两种优化方案:
// This string is definitely not interned/immutable.
zval val;
ZVAL_NEW_STR(&val, zend_string_init("test", sizeof("test")-1, 0));
// This string is definitely interned.
zval val;
ZVAL_INTERNED_STR(&val, ZSTR_CHAR('a'));
空字符串有一个单独的助手:
zval val;
ZVAL_EMPTY_STRING(&val);
如果字符串为空或者只有一个字符,则可以使用 ZVAL_STRINGL_FAST 宏来避免 zend_string 分配,因为这样的字符串总是具有可以快速获取的内部变体:
zval val;
ZVAL_STRINGL_FAST(&val, str, len);
最后,ZVAL_STR_COPY 宏是 ZVAL_STR 和 zend_string_copy 的组合,其中后者增加了字符串的引用计数:
zval val;
ZVAL_STR_COPY(&val, zstr); // Refcount will be incremented.
// More efficient/compact version of:
ZVAL_STR(&val, zend_string_copy(zstr));
对于数组,幸运的是,我们只需要考虑两个初始化宏:
zval val;
ZVAL_ARR(&val, zend_new_array(/* size_hint */ 0));
zval val;
ZVAL_EMPTY_ARRAY(&val);
第一个将数组 zval 初始化为现有的 zend_array* 结构,而后者则特别初始化一个空数组。请注意,虽然上述两个示例都初始化了一个空数组,但它们并不相同。ZVAL_EMPTY_ARRAY() 使用不可变的共享空数组,而 zend_new_array() 创建一个新数组。如果您计划之后直接修改数组,则应使用 zend_new_array() 变体。
对象 zval 使用 ZVAL_OBJ 初始化:
zval val;
ZVAL_OBJ(&val, obj_ptr);
zval val;
ZVAL_OBJ_COPY(&val, obj_ptr); // Increments refcount
虽然在处理已经存在的对象时这些方法很常见,但 object_init_ex() 是从头开始创建对象的典型方法。这将在后面关于对象的章节中介绍。
最后,使用 ZVAL_RES 初始化资源:
zval val;
ZVAL_RES(&val, zend_register_resource(ptr, le_resource_type));
分离 zvals
在 PHP 中,所有值都默认遵循按值语义。这意味着如果你写 $a = $b,那么修改 $a 将不会对 $b 产生影响,反之亦然。同时,$a = $b 本质上的实现方式如下:
zval_ptr_dtor(a);
ZVAL_COPY(a, b);
也就是说,$a 和 $b 都将指向具有递增引用计数的同一结构。这意味着对 $a 的简单修改也会修改 $b。
这就是写时复制概念的用武之地:您只允许修改您独有的结构,这意味着它们的引用计数必须为 1。如果结构的引用计数大于 1,则需要先将其分离。分离只是复制结构的一种花哨说法。
在实践中,“结构”可以用“数组”代替。虽然理论上这个概念也适用于字符串,但在 PHP 中,字符串在构造后几乎从未发生过变异。因此,SEPARATE_ARRAY() 是主要的分离宏,只能应用于 IS_ARRAY zval:
zval a, b;
ZVAL_ARR(&b, zend_new_array(0));
ZVAL_COPY(&a, &b);
SEPARATE_ARRAY(&b); // b now holds a separate copy of the array.
// Modification of b will no longer affect a.
SEPARATE_ARRAY() 宏不仅处理共享数组,还处理不可变数组:
zval val;
ZVAL_EMPTY_ARRAY(&val); // Immutable empty array.
SEPARATE_ARRAY(&val); // Mutable copy of empty array.
SEPARATE_ZVAL_NOREF() 宏可分离通用 zval,但很少有用,因为分离通常直接发生在修改之前,而且无论如何您都需要知道 zval 类型才能执行任何有意义的修改。
对象和资源不需要分离,因为它们具有类似引用的语义。