【翻译】PHP 垃圾回收机制

之所以做这个翻译,是因为官网的中文文档和英文原始文档有些不一致,另外,把英文翻译过来看,好像可以有更好地理解。
若发现错误,请随时指出。
若有建议,请随时提出。
感谢支持!

引用计数器的基本概念

PHP 变量是存放于一个叫做 "zval" 的容器中。zval 容器包含了变量的类型和值以及附加的两位 (bit) 信息。第一个叫做 "is_ref" 是个 bool 值,表示变量是否为 "reference set" 的一部分。通过这个位信息,PHP 引擎即可区分该变量是普通变量还是引用。由于 PHP 允许用户 (user-land) 通过 & 操作符来创建引用,zval 容器也包含了一个内部的引用计数机制用来优化内存使用。第二个位信息叫做 "refcount",它包含指向这个 zval 容器的变量名称 (也叫符号 (symbols) ) 个数。所有的符号都存放在符号表 (symbol table) 中,其中,每个符号都有自己的作用域 (scope)。对于主脚本 (例: 被浏览器请求的脚本) 和每个函数或方法也都有作用域。

当一个变量被赋常量值时,就会生成一个 zval 变量容器,像这样:

$a = "new string";

这个例子中,一个新的符号名 a 在当前作用域被创建了,也有一个新的变量容器被创建了,其类型是 string,值是 "new string"。"is_ref" 位默认是 FALSE,因为还没有引用被用户创建。"refcount" 为 1 因为只有一个符号在使用这个变量容器。注意,如果 "refcount" 为 1,"is_ref" 只会为 FALSE。如果你安装了 Xdebug,亦可以调用 xdebug_debug_zval() 方法来展示信息。

$a = "new string";
xdebug_debug_zval('a');

上面的例子将输出:

a: (refcount=1, is_ref=0)='new string'

将这个变量赋值给另一个变量会增加 "refcount"。

$a = "new string";
$b = $a;
xdebug_debug_zval( 'a' );

上面的例子将输出:

a: (refcount=2, is_ref=0)='new string'

现在 "refcount" 是 2,因为同一个变量容器被关联 (linked) 到了 a 和 b。PHP 足够聪明,在非必要时不会去拷贝实际的变量容器。容器变量在 "refcount" 减至 0 时被销毁。当有关联的变量容器的符号离开了作用域 (如: 函数结束时) 或者被取消赋值 (如: 被 unset() 时) 时,"refcount" 会减 1。下面的例子就能说明:

$a = "new string";
$c = $b = $a;
xdebug_debug_zval( 'a' );
$b = 42;
xdebug_debug_zval( 'a' );
unset( $c );
xdebug_debug_zval( 'a' );

上面的例子将输出:

a: (refcount=3, is_ref=0)='new string'
a: (refcount=2, is_ref=0)='new string' // when $b assigned by a new value
a: (refcount=1, is_ref=0)='new string' // when $c is unset()

如果我们现在调用 unset($a);,容器变量(包括类型和值)将从内存中被移除。

复合类型

对于复合类型而言,比如数组和对象,事情会变得稍微复杂些。和标量 (scalar) 值相反,数组和对象将自己的成员存放在自己的符号表中。这意味着下面的示例创建了 3 个 zval 容器:

$a = ['meaning' => 'life','number' => 42];
xdebug_debug_zval( 'a' );

上面的例子将输出类似这样的东西:

a: (refcount=1, is_ref=0)=array (
   'meaning' => (refcount=1, is_ref=0)='life',
   'number' => (refcount=1, is_ref=0)=42
)

或者是这样的图示:

image

3 个 zval 容器分别是: a,meaning 和 number。相似的规则同样可用来减少 "recounts"。下面,我们添加另一个元素到数组中,并将值设置为一个已存在元素的内容:

$a = ['meaning' => 'life','number' => 42];
$a['life'] = $a['meaning'];
xdebug_debug_zval( 'a' );

上面的例子将输出类似这样的东西:

a: (refcount=1, is_ref=0)=array (
   'meaning' => (refcount=2, is_ref=0)='life',
   'number' => (refcount=1, is_ref=0)=42,
   'life' => (refcount=2, is_ref=0)='life'
)

或者是这样的图示:

image

从上述 Xdebug 输出中,我们可以看出新老数组元素现在指向了一个 "refcount" 为 2 的zval 容器。虽然 Xdebug 的输出中有两个值为 "life" 的 zval 容器,但是他们其实同一个。虽然 xdebug_debug_zval() 函数不会说明这个,但你可以通过查看内存指针来分辨。

从数组中移除元素就像是将符号从作用域中移除一样。移除后,数组元素所指向的容器的 "refcount" 会被减少。同样,当 "refcount" 减至 0 时,变量容器将会从内存中移除。下面的例子可以说明:

$a = ['meaning' => 'life', 'number' => 42];
$a['life'] = $a['meaning'];
unset( $a['meaning'], $a['number'] );
xdebug_debug_zval( 'a' );

上面的例子将输出类似这样的东西:

a: (refcount=1, is_ref=0)=array (
   'life' => (refcount=1, is_ref=0)='life'
)

现在,如果我们将数组本身作为一个元素添加到数组中,事情就变得有趣了,我们将在下面的例子中这么做,而且会悄悄地添加一个引用操作符,不然 PHP 会创建一个拷贝:

$a = ['one'];
$a[] = &$a;
xdebug_debug_zval( 'a' );

上面的例子将输出类似这样的东西:

a: (refcount=2, is_ref=1)=array (
   0 => (refcount=1, is_ref=0)='one',
   1 => (refcount=2, is_ref=1)=...
)

或者是这样的图示:

image

可以看出数组变量 (a) 和 第二个元素 (1) 现在都指向了一个 "refcount" 为 2 的变量容器。上面的 "..." 表示发送了递归,当然,在这里意味着指回了起源数组。

和之前一样,对一个变量进行 unset() 会移除其符号,其指向的变量容器的引用数 (reference count) 将被减 1。所以如果我们在上述代码后面 unset 变量 $a,那么 $a 和 元素 (1) 所指向的变量容器的引用数就会减 1,由 "2" 变为 "1"。可以这样呈现:

(refcount=1, is_ref=1)=array (
   0 => (refcount=1, is_ref=0)='one',
   1 => (refcount=1, is_ref=1)=...
)

或者是这样的图示:

image

清理问题

虽然在任何作用域中都没有符号指向这个结构了,但是它无法被清理掉,因为数组元素 "1" 仍然指向自己本身。由于没有额外的符号指向它,所以对于用户来讲,是无法清理掉这个结构的,于是你就遇上了内存泄露。幸运的是,PHP 会在请求结束时清理掉这个数据结构,但在这之前,将会占去宝贵的内存空间。如果你在实现解析算法或者其他东西时将子元素指回了父元素,这种情况就会经常发生。当然,同样的情况也会发生在对象身上,而且可能性更高,因为对象总是隐式地被引用。

这样的情况发生一两次倒也不是问题,但如果发生上千次或者几十万次的内存流失,这明显就成问题了。这样的问题往往发生在长时间运行的脚本中,比如守护进程 (请求基本上永远不会结束) 或者大量的单元测试。后者,在对 eZ Components 库的 Template 组建做单元测试时,有时会需要使用超过 2GB 的内存,而测试服务也许无法满足,这便是问题。

回收周期

从传统上讲,PHP 以往使用的引用计数内存机制,无法定位循环引用内存泄露,然而自 5.3.0 起,PHP 通过实现 引用计数系统中的并发周期回收(Concurrent Cycle Collection in Reference Counted Systems) 的同步算法来解决了这个问题。

虽然对算法的完全说明有点超出这部分内容的范围,但基本的解释是有的。首先我们要建立一些基本原则。如果 "refcount" 增加了,zval 容器仍在被使用,所以这不是垃圾。如果 "refcount" 被减少了,并且被减至 0,则 zval 可以被释放。这意味着,只有当 "refcount" 被减少至非零时,垃圾周期 (garbage cycles) 才可以被产生。其次,在一次垃圾周期中,是有可能通过判断 "refcount" 是否可以被减 1,以及哪些 zval 的 "refcount" 是 0 的方式,来发现垃圾的。

image

为避免发生检查所有 refcount 可能减少的垃圾周期,该算法把所有可能的root (possible roots),即 zval 放进 "root buffer" (以紫色示意) 中。同时也确保每个可能是垃圾的 root 在 root buffer 中只出现一次。只有当 root buffer 达到饱和,回收机制才会对里面所有不同的 zval 启动。详见上图步骤 A。

在步骤 B 中,算法针对所有可能的 root 执行一次深度优先搜索,找到 zval 后对其 refcount 减 1,并确保不会在同一个 zval 上重复执行 (以灰色示意)。在步骤 C 中,算法再次对每个 root 节点进行深度优先搜索,再次检索每个 zval 的 refcount 值。如果发现 refcount 为 0,zval 则被标记成 "白色" (蓝色部分)。如果 refcount 大于 0,则算法将从此处执行深度优先搜索并回滚 refcount 减 1 操作,并将这些 zval 重新标记为 "黑色"。在最后的步骤 D 中,算法遍历整个root buffer,从中移除 zval root,同时检索出之前步骤中被标记为 "白色" 的 zval。每个被标记为 "白色" 的 zval 都将被释放。

现在你对算法是如何工作已经有了基本的认识,我们回过头来看它是如何与PHP集成的。默认情况下,PHP 的垃圾回收机制 (garbage collector) 是开启的。然而 php.ini 配置文件允许你做出修改: zend.enable_gc

当 GC 开启时,一旦 root buffer 达到饱和,上述的循环查找算法就会被执行。root buffer 固定可存放 10,000 个 root (虽然你可以通过修改位于 PHP 源码 Zend/zend_gc.c 中的 GC_ROOT_BUFFER_MAX_ENTRIES 常量,然后重编译 PHP 来改变这个数值)。当 GC 关闭时,循环查找算法将不会启动。然而,可能的 root 将永远记录在 root buffer 里,不管是否在配置中开启了 GC。

如果在 GC 关闭的情况下, root buffer 达到饱和,后续的可能的 root 就不会被记录下来。那些无法被记录的可能的 root 将永远无法被算法分析。如果它们存在循环引用,他们讲永远无法被清理掉,并造成内存泄露。

为什么在GC关闭的情况下,还是会有 root 被记录呢?是因为记录这些 root 要比在每次找到root时判断GC是否开启更快。然而,垃圾回收与分析机制本身可能会消耗相当长的时间。

除了修改 zend.enable_gc 配置,同样也可以通过调用 gc_enable() 或 gc_disable() 来控制垃圾回收机制的开与关。调用这些函数和修改配置是等效的。这同样也可以用来强制控制垃圾回收即便 root buffer 尚未饱和。你可以使用 gc_collect_cycles() 函数实现。该函数将返回被算法收集的周期数量。

允许打开和关闭垃圾回收机制并且允许自主初始化的原因,是由于你的应用程序的某部分可能是高时效性的。在这种情况下,你可能不想使用垃圾回收机制。当然,对你的应用程序的某部分关闭垃圾回收机制,是在冒着可能发生内存泄漏的风险,因为一些可能 root 也许存不进有限的 root buffer。因此,就在你调用 gc_disable() 函数释放内存之前,先调用 gc_collect_cycles() 函数可能比较明智。因为这将清除已存放在 root buffer 中的所有可能 root,然后在垃圾回收机制被关闭时,可留下空 buffer 以有更多空间存储可能 root。

《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 1
JaguarJack

这个是不是很旧的文章了 7 的内存回收新增了引用类型

4个月前 评论
DarthMinion (楼主) 4个月前
JaguarJack (作者) 4个月前

请勿发布不友善或者负能量的内容。与人为善,比聪明更重要!