Siam博客

php-gc机制的补充

2023-03-06

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());
本文链接:
版权声明: 本文由 Siam原创发布,转载请遵循《署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)》许可协议授权
Tags: PHP

扫描二维码,分享此文章