目前项目组正在使用的热更新机制有一些潜规则,其中一个就是不能更新闭包函数(因此也就不能对函数使用装饰器修饰)。

 

热更新机制原理

先来说说目前的热更新机制的原理,由于更新类是一个较为复杂的话题,因此这里只讨论更新函数的情况。

当需要热更新一个函数时:

(1)首先是调用python的built-in函数reload,这个函数会把模块重编并重新执行。

(2)然后再找出所有引用了旧函数的地方,将其替换为引用新的函数。

复杂的地方在于第二个步骤,如何做到更新所有的引用呢?看看python里面函数的实现:

typedef struct {
    PyObject_HEAD
    PyObject *func_code;    /* A code object */
    PyObject *func_globals;    /* A dictionary (other mappings won't do) */
    PyObject *func_defaults;    /* NULL or a tuple */
    PyObject *func_closure;    /* NULL or a tuple of cell objects */
    PyObject *func_doc;        /* The __doc__ attribute, can be anything */
    PyObject *func_name;    /* The __name__ attribute, a string object */
    PyObject *func_dict;    /* The __dict__ attribute, a dict or NULL */
    PyObject *func_weakreflist;    /* List of weak references */
    PyObject *func_module;    /* The __module__ attribute, can be anything */

    /* Invariant:
     *     func_closure contains the bindings for func_code->co_freevars, so
     *     PyTuple_Size(func_closure) == PyCode_GetNumFree(func_code)
     *     (func_closure may be NULL if PyCode_GetNumFree(func_code) == 0).
     */
} PyFunctionObject;

 

python的函数也是对象,并对应于c中PyFunctionObject结构体。因此这里有一个取巧的做法,只需要将PyFunctionObject结构体中的成员替换更新即可。

这个做法简单方便、易于实现,并且很多成员的替换可以在python层实现。现在项目组的热更新模块就是这样做的。

def update_function(old_fun, new_fun):
    #更新函数的PyCodeObject
    old_fun.func_code = new_fun.func_code
    #更新其它
    ...

 

python中闭包的实现

先来简单的了解一下python中闭包的实现。

def dec(f):
    def warp():
        f()
    return warp

函数dec编译后的字节码如下:

3           0 LOAD_CLOSURE             0 (f)
              3 BUILD_TUPLE              1
              6 LOAD_CONST               1 (<code object warp at 0000000002F71A30, file "test.py", line 3>)
              9 MAKE_CLOSURE             0
             12 STORE_FAST               1 (warp)

  5          15 LOAD_FAST                1 (warp)
             18 RETURN_VALUE

可以看到dec函数会将被内层函数warp引用到的对象(如f)包装成一个cellobject,再打包成一个tuple传递给warp。在虚拟机执行MAKE_CLOSURE指令时会通过PyFunction_SetClosure函数将这个tuple设置到PyFunctionObject结构的func_closure成员上。

 

为什么现在的热更新模块不支持更新闭包函数?

在了解到闭包实现之后,我们知道了在PyFunctionObject结构体上面有个成员func_closure,里面会引用住一些闭包会使用到的函数(如warp的f)。如果对函数warp热更时不替换这部分的数据,那么更新之后函数还是引用了旧的f函数!目前项目组的热更模块就是缺少对func_closure的替换。好了找到问题所在了,接下来的问题就是如何更新func_closure成员。

 

更新func_closure的第一次尝试

更新func_closure最直观的想法应该是这样的:

def update_function(old_fun, new_fun):
    #更新函数的PyCodeObject
    old_fun.func_code = new_fun.func_code
    #更新闭包数据
    old_fun.func_closure = new_fun.func_closure
    #更新其它
    ...

可惜不行,func_closure是一个readonly property。

 

更新func_closure的第二次尝试

既然不行那我遍历tuple,更新其中的cellobject总可以了吧。遗憾的是也不行,cellobject对象身上的cell_contents是不可写的(详情参考CPython源码中的cellobject.c),代码就不放上来了。

 

更新func_closure的第三次尝试

这一次我是决定直接在c里面改这个指针,这样基本可以绕过python对其的限制。具体方式是用c实现一个扩展模块:

//Note Since Python may define some pre-processor definitions 
//which affect the standard headers on some systems, 
//you must include Python.h before any standard headers are included.
#include "Python.h"

static PyObject *
PyReload_UpdateFunctionClosure(PyObject *self, PyObject *args) {
    PyObject *o1, *o2;

    if (!PyArg_ParseTuple(args, "OO", &o1, &o2)) {
        return NULL;
    }
    if (!PyFunction_Check(o1) || !PyFunction_Check(o2)) {
        return NULL;
    }
    PyObject* closure = PyFunction_GetClosure(o2);
    if (closure == NULL) {
        return NULL;
    }
    if (PyFunction_SetClosure(o1, closure) != 0) {
        return NULL;
    }
    Py_RETURN_NONE;
}

static PyMethodDef PyReload_Methods[] =
{
    { "update_function_closure",  PyReload_UpdateFunctionClosure, METH_VARARGS, "更新python函数闭包数据" },
    { NULL, NULL, 0, NULL }/* Sentinel */
};

PyMODINIT_FUNC
initPyReload(void)
{
    (void)Py_InitModule("PyReload", PyReload_Methods);
}

在python里面的更新函数可以这么写:

def update_function(old_fun, new_fun):
    #更新函数的PyCodeObject
    old_fun.func_code = new_fun.func_code
    #更新闭包数据
    if new_fun.func_closure:
        import PyReload
        PyReload.update_function_closure(old_fun, new_fun)

这样总算可以了。不过需要注意的是要正确处理好引用计数问题,还有就是不知道这段几十行的代码还有无别的问题。毕竟都千方百计不让你对func_closure进行修改了,或许这里面有坑,并且我没有注意到:)