在协程环境中使用 PHP Lua 拓展

项目迁移中遇到的最大问题是由 PHP Lua 引起的不兼容和内存泄漏问题。之前大量使用了 Lua 拓展的 registerCallback 方法来注册验证自定义逻辑的辅助方法,大致逻辑如下:

class Matcher 
{
    protected $lua;

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

    protected function validate()
    {
        // logic codes 
    }

    public function __destruct()
    {
        unset($this->lua); 
    }
}

从定义上看,registerCallback 就是将 PHP 方法保存 $this->lua 对象中。但并非如此,实际上是保存到了 Lua::$_callbacks 的静态变量里:

/* php-lua/lua.c line 864*/
zend_declare_property_null(lua_ce, ZEND_STRL("_callbacks"), ZEND_ACC_STATIC|ZEND_ACC_PRIVATE);

/* php-lua/lua.c line 761 */
PHP_METHOD(lua, registerCallback) {
    char *name;
    size_t len;
    zval *func;
    lua_State *L;
    zval* callbacks;

    if (zend_parse_parameters(ZEND_NUM_ARGS(),"sz", &name, &len, &func) == FAILURE) {
        return;
    }

    L = (Z_LUAVAL_P(getThis()))->L;

    /* 获取 _callbacks 变量 */
    callbacks = zend_read_static_property(lua_ce, ZEND_STRL("_callbacks"), 1);

    /* 初始化 _callbacks 数组  */
    if (ZVAL_IS_NULL(callbacks)) {
        array_init(callbacks);
    }

    /* 在 lua_State 里创建一个闭包,upvalue(1) 的值即为 callback 在数组中的索引  */
    if (zend_is_callable(func, 0, NULL)) {
        lua_pushnumber(L, zend_hash_num_elements(Z_ARRVAL_P(callbacks)));
        lua_pushcclosure(L, php_lua_call_callback, 1);
        lua_setglobal(L, name);
    } else {
        zend_throw_exception_ex(lua_exception_ce, 0, "invalid php callback");
        RETURN_FALSE;
    }

    /* 将 callback 加入到数组中 */
    zval_add_ref(func);
    add_next_index_zval(callbacks, func);

    RETURN_ZVAL(getThis(), 1, 0);
}

在协程环境中,由于静态变量跨请求长期存在,_callbacks 无法被正确释放,导致严重的内存泄漏。如果将注册的方法全部静态化,可以避免重复注册的问题,但是所有带状态的参数都需要显示传递,不仅会在 lua 中暴露更多细节,还让业务代码更加复杂,违背了引入 Lua 的初衷。

为了保证业务代码的兼容性,最后决定修改 Lua 拓展,将辅助方法直接保存到 Lua 的实例中。一开始是考虑直接去掉 _callbacks 静态属性,然后在注册和调用时临时替换 lua_object_handlerswrite_propertyread_property 为正常的函数指针。

但最后并没有采用,而是使用了 custom_object 的方式,单独保存 _callbacks 数组:

struct _php_lua_object {
  lua_State *L;
  zval _callbacks; /* 新增 */
  zend_object obj;
};

由于在 php_lua_call_callback 中没有直接获取 _callbacks 变量的方法,我用了一个非常 dirty 的方式,直接将 php_lua_object * 指针通过 lua_pushlightuserdata 保存到了 lua 中:

/* ... */
php_lua_object *p_lua_object = Z_LUAVAL_P(getThis());
L = p_lua_object->L;
callbacks = &(p_lua_object->_callbacks);
/* ... */
lua_pushlightuserdata(L, p_lua_object); /* 指针 */
lua_pushnumber(L, zend_hash_num_elements(Z_ARRVAL_P(callbacks)));
lua_pushcclosure(L, php_lua_call_callback, 2);
lua_setglobal(L, name);
/* ... */

c closure 执行时,直接从 upvalues 里恢复指针:

/* ... */
php_lua_object *p_lua_object = lua_touserdata(L, lua_upvalueindex(1)); /* 恢复指针 */
order = lua_tonumber(L, lua_upvalueindex(2));
/* ... */
callbacks = &(p_lua_object->_callbacks);
/* ... */
func = zend_hash_index_find(Z_ARRVAL_P(callbacks), order); /* 获取 callback */
/* ... */

zend_object 中的 handle 保存到闭包应该也能达到这个目标:

struct _zend_object {
    zend_refcounted_h gc;
    uint32_t          handle; // TODO: may be removed ???
    zend_class_entry *ce;
    const zend_object_handlers *handlers;
    HashTable        *properties;
    zval              properties_table[1];
};

不过由于 PHP 开发者多次提到 EG(objects_store) 已经没有什么太大的用处,上面的注释也可以证明,还是放弃使用为妙。另外,如果将 _callback 指针直接保存到 lua_State 中也可以考虑,这样就不用像我这样修改 send_zval 的参数了。

具体的修改记录可以访问 php-lua 查看,但是切勿用于生成环境,后面有空后会考虑更好的实现方式。使用修改后的拓展也需要注意手动释放 Lua 对象。比如在文章开头的使用方式中,由于 Lua 对象和 Matcher 对象存在循环引用,单独 unset($matcher) 并不会立刻触发 Matcher 的析构函数,同样存在内存泄漏风险。