php-gc
高级语言的特征:内存gc机制
,可以对内存空间自行管理释放,对程序员的心智负担少了很多。
在php官网上 垃圾回收机制的地址是这个 -> https://www.php.net/manual/zh/features.gc.php
关于引用计数基本知识
这些,相信大家都在官网或者网上的文章看到过,这也不是本篇文章的重点,所以官网的文档先看看哈。
本文的主要作用是补充一下过时没有及时更新的gc说明
变量引用计数类型
一个zval变量,拥有一个计数值,表明被多少个地方使用
,如果等于0,则为空闲变量,可以回收。
从zval的引用类型来分析,可以分为俩大类
- 普通引用
- 循环引用
普通引用的gc比较简单,主要的问题出在循环引用上
循环引用
$a = new A;
$b = new B;
$a->b = $b;
$b->a = $a;
这里俩个变量互相指认,所以每次gc在浅运行的时候都判断到 $a, $b 计数值=1,无法释放
这种循环引用,需要php在gc深度遍历进行分析,确认只是几个变量之间循环引用,而没有外部引用,才能gc
运行周期
以上是gc运行逻辑的简单介绍,接下来介绍gc运行的周期
分为俩种情况
- 缓冲池满了
- 程序中手动调用
在官网 官网文档 中描述
为避免不得不检查所有引用计数可能减少的垃圾周期,这个算法把所有可能根(possible roots 都是zval变量容器),放在根缓冲区(root buffer)中(用紫色来标记,称为疑似垃圾),这样可以同时确保每个可能的垃圾根(possible garbage root)在缓冲区中只出现一次。仅仅在根缓冲区满了时,才对缓冲区内部所有不同的变量容器执行垃圾回收操作。
也就是说,zend引擎中有一个根缓存区
,存储着 可能的垃圾根zval
。
根缓存区有固定的大小,可存10,000个可能根,当然你可以通过修改PHP源码文件Zend/zend_gc.c中的常量GC_ROOT_BUFFER_MAX_ENTRIES,然后重新编译PHP
简单来说,就是如果根缓存区放满了1万个脏zval,就会触发gc来释放内存
此时池子里 不全是
循环引用的zval,也会有用普通引用的zval。
补充文档
以下是本文的重点,可能内容不多,但是是对官网文档的补充描述。
写下本文的时候php已经发布了8.2,我们正在使用8.1。
- 根缓存区不是固定的大小,是可以变动的,我们可称为gc阈值。
- 根缓存区的变化是根据 每次gc 释放出来掉的zval的类型占比来分析的。
源码分析
以下是php的源码,我把其中的注释改成中文,我的理解:根缓存区的容量,与文档目前描述的 固定大小不符,是一个可伸缩的空间
#define GC_THRESHOLD_DEFAULT (10000 + GC_FIRST_ROOT)
#define GC_THRESHOLD_STEP 10000
#define GC_THRESHOLD_MAX 1000000000
#define GC_THRESHOLD_TRIGGER 100
static void gc_adjust_threshold(int count)
{
uint32_t new_threshold;
// count变量: 本次gc释放掉的循环引用zval数量。跟php函数 `gc_collect_cycles` 的返回值一致
// GC_THRESHOLD_TRIGGER: 是一个常量 100
// 所以这里的逻辑是:
// 1. 如果本次gc释放的循环引用zval数量低于100,zend引擎认为当前程序不需要太过于频繁的深度解析gc。
// 2. 根缓存区的容量扩大,扩大的值为 `+ GC_THRESHOLD_STEP` 一万
// 3. 如果gc的循环引用zval数量大于100,则会把根缓存区的容量缩小
if (count < GC_THRESHOLD_TRIGGER) {
/* increase */
if (GC_G(gc_threshold) < GC_THRESHOLD_MAX) {
new_threshold = GC_G(gc_threshold) + GC_THRESHOLD_STEP;
if (new_threshold > GC_THRESHOLD_MAX) {
new_threshold = GC_THRESHOLD_MAX;
}
if (new_threshold > GC_G(buf_size)) {
gc_grow_root_buffer();
}
if (new_threshold <= GC_G(buf_size)) {
GC_G(gc_threshold) = new_threshold;
}
}
} else if (GC_G(gc_threshold) > GC_THRESHOLD_DEFAULT) {
new_threshold = GC_G(gc_threshold) - GC_THRESHOLD_STEP;
if (new_threshold < GC_THRESHOLD_DEFAULT) {
new_threshold = GC_THRESHOLD_DEFAULT;
}
GC_G(gc_threshold) = new_threshold;
}
}
内存泄漏
在8.1.10
前,拥有析构函数的场景下,php对于gc释放计数存在问题(在析构函数执行后再次gc,此时计数被重置为0,正确的应该为累计)
- 相关issue:
https://github.com/php/php-src/issues/9266
- 相关修复:
https://github.com/php/php-src/commit/31a99331c1eb8de11ae3dbf0c336fead90d239cc
我们遇到此种问题的场景是:常驻进程,消费队列中,使用到某个组件就会存在内存泄漏
由于是消费队列,所以任务是比较单一的,也会反复执行,所以导致了不断触发到以上描述的bug
计数错误 -> 根缓存区不断扩容 -> 占用内存嘎嘎上涨
此种情况下的内存泄漏曲线如下 每gc一次,下一次运行的内存上限就提升了
PHP复现
以下代码是PHP PR中可复现问题的例子,其中的关键是
public function __destruct()
{
new Bar();
}
首次gc,对象被释放,才会触发__destruct
此时又new了类,放入池子内,所以php考虑到这种场景,会在析构函数执行完成之后再进行一次gc
(以上细节可以看 zend_gc.c 源码)
就是在此步骤出了计数置零
的bug
class GlobalData
{
public static Bar $bar;
}
class Value
{
public function __destruct()
{
new Bar();
}
}
class Bar
{
public function __construct()
{
GlobalData::$bar = $this;
}
}
class Foo
{
public Foo $selfRef;
public Value $val;
public function __construct(Value $val)
{
$this->val = $val;
$this->selfRef = $this;
}
}
for ($j = 0; $j < 10; $j++) {
for ($i = 0; $i < 3000; $i++) {
new Foo(new Value());
}
}
var_dump(gc_status());
扫描二维码,分享此文章