PHP 对象释放逻辑探究

书接上回,在修改 PHP-Lua 拓展时,遇到了一个奇怪的问题。同样的代码,在特定版本运行正常,其他版本会提示内存泄漏。

class Call 
{
    public $lua;
    public function __construct()
    {
        $this->lua = new Lua();
        $this->lua->registerCallback('dump', [$this, 'dump']);
    }
    public function dump(){
    }
}
$t = new Call();

这段测试代码会在 PHP 7.2.24 版中可以正确退出,但是在 PHP 7.4 或者更高的 PHP 8 中就无法通过测试:

002+ /path/to/php-lua/lua.c(207) :  Freeing 0x00007f71fc8598a0 (64 bytes), 
    script=/path/to/php-lua/tests/bug73964.php
003+ === Total 1 memory leaks detected ===

引起问题的原因是我改变了 php_lua_object 对象的 dtorfree 函数的逻辑,原版中释放逻辑如下:

static void php_lua_dtor_object(zend_object *object) {
    php_lua_object *lua_obj = php_lua_obj_from_obj(object);

    zend_object_std_dtor(object);
}

static void php_lua_free_object(zend_object *object) {
    php_lua_object *lua_obj = php_lua_obj_from_obj(object);

    if (lua_obj->L) {
        lua_close(lua_obj->L);
    }
}

由于我把 _callbacks 数组放到了 php_lua_object 中,于是直接沿用作者释放 lua_closure_object 的逻辑,去掉了 dtor 部分:

/* 原版 lua_closure_object 释放逻辑 */
static void php_lua_closure_free_obj(zend_object *zobj) /* {{{ */
{
    lua_closure_object *objval = php_lua_closure_object_from_zend_object(zobj);
    if ((Z_TYPE(objval->lua) == IS_OBJECT) &&
        instanceof_function(Z_OBJCE(objval->lua), lua_ce)) {
        luaL_unref((Z_LUAVAL(objval->lua))->L, LUA_REGISTRYINDEX, objval->closure);
    }
    zval_dtor(&(objval->lua));
    zend_object_std_dtor(zobj);
}

/* 沿用上面逻辑,修改后的 php_lua_object 释放逻辑 */
static void php_lua_free_object(zend_object *object) /* {{{ */ 
{
    php_lua_object *lua_obj = php_lua_obj_from_obj(object);
    if (lua_obj->L) {
        lua_close(lua_obj->L);
    }
    zval_ptr_dtor(&(lua_obj->_callbacks));
    zend_object_std_dtor(object);
}

在测试代码中没有手动 unset 的操作,对象都是在代码执行结束后由系统自动释放的,所以可以大致确定对象之间的引用如下:

引用关系

变量 $t 对象的 zend_object 的 refcount 为2,分别由 $t_callbacks 贡献,_callbacks 又和 $t->luazend_object 绑定,组成了一个特别的循环引用。在这个测试中,PHP 引擎回收对象时涉及的操作大致有 zend_hash_graceful_reverse_destroyzend_objects_store_call_destructorszend_objects_store_free_object_storage

先列个约定,在后面的内容中把属于变量 $t 的 zend_object 称为 zend_object T,把属于变量 $lua 的 zend_object 称为 zend_object L,方便区分和叙述。

通过 GDB 在对应的函数上打上断点来查看下释放对象时引用的变化情况:

$ gdb php8
GNU gdb (Ubuntu 8.1.1-0ubuntu1) 8.1.1
Copyright (C) 2018 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from php8...done.
(gdb) b zend_hash_graceful_reverse_destroy
Breakpoint 1 at 0x809609: file /source/php-src/Zend/zend_hash.c, line 1798.
(gdb) b zend_objects_store_call_destructors
Breakpoint 2 at 0x8dcc9d: file /source/php-src/Zend/zend_objects_API.c, line 44.
(gdb) b zend_objects_store_free_object_storage
Breakpoint 3 at 0x8dce4e: file /source/php-src/Zend/zend_objects_API.c, line 86.
(gdb) 

执行测试代码:

(gdb) r -dextension=lua call.php
Starting program: /bin/php8 -dextension=lua call.php
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, zend_hash_graceful_reverse_destroy (ht=0x555556bb0888) at /source/php-src/Zend/zend_hash.c:1798
warning: Source file is more recent than executable.
1798            IS_CONSISTENT(ht);
(gdb) c
Continuing.

Breakpoint 2, zend_objects_store_call_destructors (objects=0x555556bb0968) at /source/php-src/Zend/zend_objects_API.c:44
warning: Source file is more recent than executable.
44              EG(flags) |= EG_FLAGS_OBJECT_STORE_NO_REUSE;

这里需要跳过第一个无关的断点,在 PHP 结束阶段,应该是先执行 shutdown_destructors 调用 zend_objects_store_call_destructors,然后才是 shutdown_executor 调用其他的。

(gdb) n
45              if (objects->top > 1) {
(gdb) 
47                      for (i = 1; i < objects->top; i++) {
(gdb) 
48                              zend_object *obj = objects->object_buckets[i];
(gdb) 
49                              if (IS_OBJ_VALID(obj)) {
(gdb) p (char *)obj->ce->name->val
$1 = 0x7fffed002e18 "Call"
(gdb) p obj->gc
$2 = {refcount = 2, u = {type_info = 3221227528}}
(gdb) n
50                                      if (!(OBJ_FLAGS(obj) & IS_OBJ_DESTRUCTOR_CALLED)) {
(gdb) 
51                                              GC_ADD_FLAGS(obj, IS_OBJ_DESTRUCTOR_CALLED);
(gdb) 
53                                              if (obj->handlers->dtor_obj != zend_objects_destroy_object
(gdb) 
54                                                              || obj->ce->destructor) {
(gdb) 
47                      for (i = 1; i < objects->top; i++) {

zend_object T 首先调用 dtor,调用前 refcount 为2,和预想一样。由于 Call 类并没有定义 dtor,并没有执行相应的操作,只是打上了 IS_OBJ_DESTRUCTOR_CALLED 的 flag,循环后 refcount 仍然为2。

48                              zend_object *obj = objects->object_buckets[i];
(gdb) 
49                              if (IS_OBJ_VALID(obj)) {
(gdb) p (char *)obj->ce->name->val
$9 = 0x555556dd8968 "Lua"
(gdb) p obj->gc
$10 = {refcount = 1, u = {type_info = 3221226504}}
(gdb) n
50                                      if (!(OBJ_FLAGS(obj) & IS_OBJ_DESTRUCTOR_CALLED)) {
(gdb) 
51                                              GC_ADD_FLAGS(obj, IS_OBJ_DESTRUCTOR_CALLED);
(gdb) 
53                                              if (obj->handlers->dtor_obj != zend_objects_destroy_object
(gdb) 
55                                                      GC_ADDREF(obj);
(gdb) 
56                                                      obj->handlers->dtor_obj(obj);
(gdb) 
57                                                      GC_DELREF(obj);
(gdb) p obj->gc
$11 = {refcount = 2, u = {type_info = 3221226760}}

然后是 zend_object L 调用 dtor,由于我移除了原本的 dtor,与上面一样,除了打上 flag 外并没有其他操作,refcount 为 1。

Breakpoint 1, zend_hash_graceful_reverse_destroy (ht=0x555556bb0750) at /source/php-src/Zend/zend_hash.c:1798
1798            IS_CONSISTENT(ht);
(gdb) n
1799            HT_ASSERT_RC1(ht);
(gdb) 
1801            idx = ht->nNumUsed;
(gdb) 
1802            p = ht->arData + ht->nNumUsed;
(gdb) 
1803            while (idx > 0) {
(gdb) 
1804                    idx--;
(gdb) 
1805                    p--;
(gdb) 
1806                    if (UNEXPECTED(Z_TYPE(p->val) == IS_UNDEF)) continue;
(gdb) p (char *)p->key->val
$14 = 0x555556bbd468 "t"
(gdb) p (char *)p->val->value->obj->ce->name->val
$15 = 0x7fffed002e18 "Call"
(gdb) p p->val->value->obj->gc
$16 = {refcount = 2, u = {type_info = 3221227784}}
(gdb) n
1807                    _zend_hash_del_el(ht, HT_IDX_TO_HASH(idx), p);
(gdb) n
1803            while (idx > 0) {
(gdb) p p->val->value->obj->gc
$17 = {refcount = 1, u = {type_info = 3221227784}}

随后会进入到 symbol_table 的销毁逻辑,这里 zend_object T 的引用被减1,执行后 refcount 变为1。

Breakpoint 3, zend_objects_store_free_object_storage (objects=0x555556bb0968, fast_shutdown=false)
    at /source/php-src/Zend/zend_objects_API.c:86
86              if (objects->top <= 1) {
(gdb) n
92              end = objects->object_buckets + 1;
(gdb) n
93              obj_ptr = objects->object_buckets + objects->top;
(gdb) n
95              if (fast_shutdown) {
(gdb) n
111                             obj_ptr--;
(gdb) n
112                             obj = *obj_ptr;
(gdb) n
113                             if (IS_OBJ_VALID(obj)) {
(gdb) p (char *)obj->ce->name->val
$18 = 0x555556dd8968 "Lua"
(gdb) p obj->gc
$19 = {refcount = 1, u = {type_info = 264}}
(gdb) n
114                                     if (!(OBJ_FLAGS(obj) & IS_OBJ_FREE_CALLED)) {
(gdb) n
115                                             GC_ADD_FLAGS(obj, IS_OBJ_FREE_CALLED);
(gdb) n
116                                             GC_ADDREF(obj); /* 这里将 refcount + 1 */
(gdb) n
117                                             obj->handlers->free_obj(obj);
(gdb) n
120                     } while (obj_ptr != end);
(gdb) p obj->gc
$20 = {refcount = 1, u = {type_info = 3221227272}}
(gdb) n
111                             obj_ptr--;
(gdb) n
112                             obj = *obj_ptr;
(gdb) n
113                             if (IS_OBJ_VALID(obj)) {
(gdb) p obj
$21 = (zend_object *) 0xffffffffffffffff

zend_objects_store_free_object_storage 调用 free 顺序和之前不同,zend_object L 先执行 php_lua_free_object,然后整个循环就结束了。这是因为在 php_lua_free_object 函数中销毁了 _callbacks 数组,会将 zend_object T 的 refcount 再次减1变为0,此时会调用 zend_objects_store_del 将其直接回收。

ZEND_API void ZEND_FASTCALL zend_objects_store_del(zend_object *object)
{
    ZEND_ASSERT(GC_REFCOUNT(object) == 0);

    /* GC might have released this object already. */
    if (UNEXPECTED(GC_TYPE(object) == IS_NULL)) {
        return;
    }

    /* 这里并不会执行 */
    if (!(OBJ_FLAGS(object) & IS_OBJ_DESTRUCTOR_CALLED)) {
        GC_ADD_FLAGS(object, IS_OBJ_DESTRUCTOR_CALLED);

        if (object->handlers->dtor_obj != zend_objects_destroy_object
                || object->ce->destructor) {
            GC_SET_REFCOUNT(object, 1);
            object->handlers->dtor_obj(object);
            GC_DELREF(object);
        }
    }

    if (GC_REFCOUNT(object) == 0) {
        uint32_t handle = object->handle;
        void *ptr;

        ZEND_ASSERT(EG(objects_store).object_buckets != NULL);
        ZEND_ASSERT(IS_OBJ_VALID(EG(objects_store).object_buckets[handle]));
        EG(objects_store).object_buckets[handle] = SET_OBJ_INVALID(object);
        if (!(OBJ_FLAGS(object) & IS_OBJ_FREE_CALLED)) {
            GC_ADD_FLAGS(object, IS_OBJ_FREE_CALLED);
            GC_SET_REFCOUNT(object, 1);
            object->handlers->free_obj(object);
        }
        ptr = ((char*)object) - object->handlers->offset;
        GC_REMOVE_FROM_BUFFER(object);
        efree(ptr);
        ZEND_OBJECTS_STORE_ADD_TO_FREE_LIST(handle);
    }
}

在循环中 zend_object L 触发了 zend_object T 的回收逻辑,但是 zend_object L 在调用 free_obj 之前 refcount 被加了 1。也就是说,在 zend_object T 销毁 properties_table 时 zend_object L 的 refcount 为2,不会被回收,这就是内存泄漏出现的关键原因了。

那为什么在 PHP 7.2.24 中却没有出现内存泄漏呢?既然原因已经找到,那么就可以直接从代码中找到不同:

/* PHP 7.2.24 */
void shutdown_executor(void)
{
    /* ... */
    zend_objects_store_free_object_storage(&EG(objects_store), fast_shutdown);
    /* ... */
    if (fast_shutdown) {
        /* ... */
    } else {
        zend_hash_graceful_reverse_destroy(&EG(symbol_table));
        /* ... */
    }
    /* ... *
}

在 PHP 7.2.24 的代码中是先执行了 zend_objects_store_free_object_storage 然后再回收 symbol_table。也就是说在调用 free 时指向 zend_object T 的 refcount 会从2减为1,此时并不会和之前一样循环一次就结束,第二次循环时会调用默认的 zend_object_std_dtor 回收 zend_object T 的 properties_table,继而正确回收 zend_object L。

然后回收 symbol_table 中的 $t 时,zend_object T 的 refcount 会减为0,触发 zend_objects_store_del 完成回收。

因此要保证内存正确回收,不能把 php_lua_objectdtor 省略,需改为:

static void php_lua_dtor_object(zend_object *object)
{
    php_lua_object *lua_obj = php_lua_obj_from_obj(object);
    /* 在 dtor 中释放 _callbacks 数组 */
    zval_ptr_dtor(&(lua_obj->_callbacks));

    zend_object_std_dtor(object);
}

static void php_lua_free_object(zend_object *object)
{
    php_lua_object *lua_obj = php_lua_obj_from_obj(object);

    if (lua_obj->L) {
        lua_close(lua_obj->L);
    }
}