python基于函数替换的热更新原理介绍

热更新即在不重启进程或者不离开Python interpreter的情况下使得被编辑之后的python源码能够直接生效并按照预期被执行新代码。平常开发中,热更能极大提高程序开发和调试的效率,在修复线上bug中更是扮演重要的角色。但是要想实现一个理想可靠的热更模块又非常的困难。

1.基于reload

reload作为python官方提供的module更新方式,有一定作用,但是很大程度上并不能满足热更的需求。

先来看一下下面的问题:

>>> import sys, math
>>> reload(math)
<module 'math' (built-in)>
>>> sys.modules.pop('math')
<module 'math' (built-in)>
>>> __import__('math')
<module 'math' (built-in)>
>>> reload(math)
Traceback (most recent call last):
  File "<pyshell#4>", line 1, in <module>
    reload(math)
ImportError: reload(): module math not in sys.modules
>>> sys.modules.get('math')
<module 'math' (built-in)>
>>> id(math), id(sys.modules.get('math'))
(45429424, 45540272)

函数 __import__ 会在import声明中被调用。import导入一个模块分两步:

  1. find a module, and initialize it if necessary;
  2. define a name or names in the local namespace;

 其中第一步有以下的搜寻过程:a): sys.modules; b): sys.meta_path; c):sys.path_hooks, sys.path_importer_cache, and sys.path

上面例子中math从缓存sys.modules移除后,__import__会重新load math并添加到sys.modules,导致当前环境中math绑定的math module和sys.modules中不一致,导致reload失败。

热更使用reload并动态的使用__import__导入很容易犯该错误,另外reload要求模块之前已经被正确的引入。

# -*- coding:utf-8 -*-
import time, os, sys
import hotfix
# r = hotfix.gl_var

# @singleton
class ReloadMgr(object):
    to_be_reload = ('hotfix',)
    check_interval = 1.0
    def __init__(self):
        self._mod_mtime = dict(map(lambda mod_name: (mod_name, self.get_mtime(mod_name)), self.to_be_reload))

    def polling(self):
        while True:
            time.sleep(1)
            self._do_reload()

    def _do_reload(self):
        for re_mod in self.to_be_reload:
            last_mtime = self._mod_mtime.get(re_mod, None)
            cur_mtime = self.get_mtime(re_mod)
            if cur_mtime and last_mtime != cur_mtime:
                self._mod_mtime.update({re_mod:cur_mtime})
                ld_mod = sys.modules.get(re_mod)
                reload(ld_mod)

    @staticmethod
    def get_mtime( mod_name):
        ld_mod = sys.modules.get(mod_name)
        file = getattr(ld_mod, '__file__', None)
        if os.path.isfile(file):
            file = file[:-1] if file[-4:] in ('.pyc', '.pyo') else file
            if file.endswith('.py'):
                return os.stat(file).st_mtime 
        return None

if __name__ == '__main__':
    reload_mgr = ReloadMgr()
    reload_mgr.polling()
View Code

上面的这个例子轮询检测已经被导入过的指定模块的源代码是否被修改过,如果被修改过,使用reload更新模块。这种方式思路清晰,实现简单,然而并没有太大的实际用途。主要原因如下:

  • 通过 from mod_a import var_b 的方式在mod_c模块中引入的变量var_b并不会随着reload(mod_a)而更新,var_b将依旧引用旧值。该问题同样存在于引入的函数和类;可以重新执行from语句或者通过mod_a.var_b的方式使用var_b。显然如果在mod_c中引入了mod_a的一个类mod_a_cls并且有一个对象a_cls_obj,要想是a_cls_obj执行新代码,必须要重新生成一个新的对象。
  • 用指令触发主动的进行更新可能较为实际,避免修改错误或者只修改了若干文件中的一个就触发更新导致错误;
  • 指定检测更新模块的方式不灵活,且要求先前导入过被检测模块;
  • 更新完成后主进程被阻塞,直到下一次更新检测。

因此,本质上这个程序仅仅是用作检测文件修改并使用reload更新,根本的缺陷是旧的对象不能执行新的代码,需要重新生成新的对象。可以应用于特定少量文件的更新。

2.基于进程/线程检测

 针对上面介绍的一个例子存在的问题,可以使用进程或者线程将模块修改检测的工作和程序的执行分离开来。

大致思路就是,不直接启动主程序,而是启动一个检测程序,在检测程序中创建一个进程或者线程来执行主程序。

./MainProgram.py

 1 # -*- coding:utf-8 -*-
 2 import time
 3 # import cnblogs.alpha_panda
 4 
 5 cnt = 0
 6 
 7 def tick():
 8     global cnt
 9     print __name__, cnt
10     cnt += 1
11 
12 def start_main_loop():
13     frame_time = 1
14     while True:
15         time.sleep(frame_time)
16         tick()
17 
18 def start_program():
19     print 'program running...'
20     start_main_loop()
21 
22 if __name__ == '__main__':
23     start_program()
View Code

 ./Entry.py

 1 # -*- coding:utf-8 -*-
 2 
 3 import os, sys
 4 import threading, time, subprocess
 5 import MainProgram
 6 
 7 class Checker():
 8     def __init__(self):
 9         self._main_process = None
10         self._check_interval = 1.0
11         self._exclude_mod = (__name__, )
12         self._entry_program = r'./MainProgram.py'
13         self._mod_mtime = dict(map(lambda mod_name: (mod_name, self.get_mtime(mod_name)), sys.modules.iterkeys()))
14         self._start_time = 0
15 
16     def start(self):
17         self._initiate_main_program()
18         self._initiate_checker()
19 
20     def _initiate_main_program(self):
21         # self._main_process = subprocess.Popen([sys.executable, self._entry_program])
22         main_thread = threading.Thread(target = MainProgram.start_program)
23         main_thread.setDaemon(True)
24         main_thread.start()
25         self._start_time = time.time()
26 
27     def _initiate_checker(self):
28         while True:
29             try:
30                 self._do_check()
31             except KeyboardInterrupt:
32                 sys.exit(1)
33 
34     def _do_check(self):
35         sys.stdout.flush()
36         time.sleep(self._check_interval)
37         if self._is_change_running_code():
38             print 'The elapsed time: %.3f' % (time.time() - self._start_time)
39             # self._main_process.kill()
40             # self._main_process.wait()
41             sys.exit(5666)
42 
43     def _is_change_running_code(self):
44         for mod_name in sys.modules.iterkeys():
45             if mod_name in self._exclude_mod:
46                 continue
47             cur_mtime = self.get_mtime(mod_name)
48             last_mtime = self._mod_mtime.get(mod_name)
49             if cur_mtime != self._mod_mtime:
50                 # 更新程序运行过程中可能导入的新模块
51                 self._mod_mtime.update({mod_name : cur_mtime})
52                 if last_mtime and cur_mtime > last_mtime:
53                     return True 
54         return False
55 
56     @staticmethod
57     def get_mtime( mod_name):
58         ld_mod = sys.modules.get(mod_name)
59         file = getattr(ld_mod, '__file__', None)
60         if file and os.path.isfile(file):
61             file = file[:-1] if file[-4:] in ('.pyc', '.pyo') else file
62             if file.endswith('.py'):
63                 return os.stat(file).st_mtime 
64         return None
65 
66 if __name__ == '__main__':
67     print 'Enter entry point...'
68     check = Checker()
69     check.start()
70     print 'Entry Exit!'
View Code

 ./Reloader.py

 1 def set_sentry():
 2     while True:
 3         print '====== restart main program... ====='
 4         sub_process = subprocess.Popen([sys.executable, r'./Entry.py'],
 5             stdout = None,    #subprocess.PIPE
 6             stderr = subprocess.STDOUT,)
 7         exit_code = sub_process.wait()
 8         print 'sub_process exit code:', exit_code
 9         if exit_code != 5666:
10             # 非文件修改导致的程序异常退出,没必要进行重启操作
11             print 'main program exit code: %d' % exit_code
12             break
13 
14 if __name__ == '__main__':
15     try:
16         set_sentry()
17     except KeyboardInterrupt:
18         sys.exit(1)
View Code

运行Reloader.py,然后在编辑器中修改mainProgram.py,结果如下:

====== restart main program... =====
Enter entry point...
program is running...
MainProgram 0
MainProgram 1
MainProgram 2
MainProgram 3
MainProgram 4
MainProgram 5
The elapsed time: 6.000
sub_process exit code: 5666
====== restart main program... =====
Enter entry point...
program is running...
MainProgram 0
MainProgram 100
MainProgram 200
MainProgram 300
[Cancelled]
View Code

这其中的主要涉及的问题如下:

  • 检测程序和主程序要分别位于不同进程/线程,并且要能共享进程资源;
  • 检测程序主动退出时,执行主程序的线程要关闭掉(注意:python threading没有提供直接kill线程的接口);

以上问题决定了检测程序和主程序要分别以子进程及其创建的线程的方式运行。

上面的程序中并没有通过遍历工程目录的所有文件的改动状况来重启程序,而是只检测已经被加载到内存中的模块,避免修改暂时没有被使用的文件导致错误的重启。

这个例子仅仅是为了展示一种思路,将线程设置为守护线程以强迫其随着创建进程的结束而退出的做法可能导致资源没有正确释放。

但这种方式本质上并不是热更,也没有保留程序的执行状态,可以看做是一个自动化重启的工具。

3.基于函数替换

下面我们从简单到深入一步步的说明函数替换的热更原理。

3.1 __dict__ vs attrs

先来看一个简例:

class Foo(object):
    STA_MEM = 'sta_member variable'
@staticmethod def sta_func(): print 'static_func'
@classmethod def cls_func(cls): print 'cls_func'

def func(self): print "member func"

 下面比较一下上面类中定义的三个函数:

comp = [(Foo.sta_func, Foo.__dict__['sta_func']),(Foo.cls_func, Foo.__dict__['cls_func']),(Foo.func, Foo.__dict__['func'])]
for attr_func, dic_func in comp:
    for func in (attr_func, dic_func):
        print func, type(func), id(func), inspect.ismethod(func), inspect.isfunction(func), isinstance(func, classmethod), isinstance(func, staticmethod)

 看一下比较结果:

<function sta_func at 0x027072B0>                 <type 'function'>      40923824  False  True  False  False
<staticmethod object at 0x026FAC90>               <type 'staticmethod'>    40873104  False   False  False   True

<bound method type.cls_func of <class '__main__.Foo'>>    <type 'instancemethod'>   40885944 True False False False
<classmethod object at 0x026FAD50>                <type 'classmethod'>     40873296 False False True False

<unbound method Foo.func>                      <type 'instancemethod'>  40886024 True False False False
<function func at 0x02707B70>                   <type 'function'>      40926064 False True False False

可以看到Foo.func和Foo.__dict__['func']获取的并不是同一个对象,类型也不同。

简单可以理解为对于类类型,__dict__中包含的是类的namespace。里面是原生的函数定义,而通过点运算符得到的是类的属性。

关于这个详细解释可以参考instancemethod or function 和 from function to method . 这里不做过多说明。

3.2 运行时替换对象成员函数

为了便于说明如何在程序运行时替换函数,下面刻意设计的一个简单的例子:

 ./hotfix.py

# -*- coding:utf-8 -*-
gl_var = 0

class Foo(object):
    def __init__(self):
        self.cur_mod = __name__
    def bar(self):
        print 'This is Foo member func bar, self.cur_mod = %s' % self.cur_mod

f = Foo()
f.bar()
print 'hotfix gl_var = %d\n' % gl_var

 ./reloader.py (只使用reload)

import hotfix

if __name__ == '__main__':
    foo = hotfix.Foo()
    foo.cur_mod = __name__
    cmd = 1
    while 1 == cmd:
        reload(hotfix)
        foo.bar()
        cmd = input()

运行测试结果:

G:\Cnblogs\Alpha Panda>python Reloader.py
This is Foo member func bar, self.cur_mod = hotfix
hotfix gl_var = 0

This is Foo member func bar, self.cur_mod = hotfix
hotfix gl_var = 0

This is Foo member func bar, self.cur_mod = __main__
####### 修改hotfix.Foo.bar函数的定义 #######
1
After Modified! This is Foo member func bar, self.cur_mod = hotfix
hotfix gl_var = 0

This is Foo member func bar, self.cur_mod = __main__
View Code

 上面的结果说明修改hotfix.Foo.bar的定义并reload之后,新定义的函数对于新建的对象是生效的,但是对于已经存在的对象reloader.foo并不生效。下面添加函数替换:

 1 import hotfix
 2 
 3 def reload_with_func_replace():
 4     old_cls = hotfix.Foo
 5     reload(hotfix)
 6     for name, value in hotfix.Foo.__dict__.iteritems():
 7         if inspect.isfunction(value) and name not in ('__init__'):
 8             # setattr(foo.bar, 'func_code', hotfix.Foo.bar.func_code)
 9             old_func = old_cls.__dict__[name]
10             setattr(old_func, "func_code", value.func_code)
11     setattr(hotfix, 'Foo', old_cls)
12 
13 if __name__ == '__main__':
14     foo = hotfix.Foo()
15     foo.cur_mod = __name__
16     cmd = 1
17     while 1 == cmd:
18         reload_with_func_replace()
19         foo.bar()
20         cmd = input()

 看一下测试结果:

G:\Cnblogs\Alpha Panda>python Reloader.py
This is Foo member func bar, self.cur_mod = hotfix
hotfix gl_var = 0

This is Foo member func bar, self.cur_mod = hotfix
hotfix gl_var = 0

This is Foo member func bar, self.cur_mod = __main__
1
After Modified! This is Foo member func bar, self.cur_mod = hotfix
hotfix gl_var = 0

After Modified! This is Foo member func bar, self.cur_mod = __main__
View Code

在没有重新创建reloader模块中的对象foo的情况下,被修改后的函数代码被执行了,而且对象的状态(self.cur_mod)被保留下来了。

3.3 函数替换一般化

显然上面的代码只是为了演示,使用reload要事先知道并确定模块,而且只能运用于绑定到模块的变量上,程序运行过程中通过sys.modules拿到的模块都是是str类型的,因此使用runtime使用reload显然不合适。

 1 RELOAD_MOD_LIST = ('hotfix',)
 2 
 3 def do_replace_func(new_func, old_func):
 4     # 暂时不支持closure的处理
 5     re_attrs = ('func_doc', 'func_code', 'func_dict', 'func_defaults')
 6     for attr_name in re_attrs:
 7         setattr(old_func, attr_name, getattr(new_func, attr_name, None))
 8 
 9 def update_type(cls_name, old_mod, new_mod, new_cls):
10     old_cls = getattr(old_mod, cls_name, None)
11     if old_cls:
12         for name, new_attr in new_cls.__dict__.iteritems():
13             old_attr = old_cls.__dict__.get(name, None)
14             if new_attr and not old_attr:
15                 setattr(old_cls, name, new_attr)
16                 continue
17             if inspect.isfunction(new_attr) and inspect.isfunction(old_attr):
18                 do_replace_func(new_attr, old_attr)
19                 # setattr(old_cls, name, new_attr)
20         setattr(new_mod, cls_name, old_cls)
21 
22 def reload_with_func_replace():
23     for mod_name in RELOAD_MOD_LIST:
24         old_mod = sys.modules.pop(mod_name)        # Not reload(hotfix)
25         __import__(mod_name)                    # Not hotfix = __import__('hotfix')
26         new_mod = sys.modules.get(mod_name)
27         for name, new_attr in inspect.getmembers(new_mod):
28             if new_attr is not type and isinstance(new_attr, type):
29                 update_type(name, old_mod, new_mod, new_attr)

上面重写了3.2中的reload_with_func_replace,这样只要在RELOAD_MOD_LIST中指定需要热更的模块或者定义一个忽略热更的列表模块,然后需要的时候触发一个指令调用上面的热更流程,便可实现运行时对sys.modules中部分模块实施热更新。

加上对闭包的处理:

def do_replace_func(new_func, old_func, is_closure = False):
    # 简单的closure的处理
    re_attrs = ('func_doc', 'func_code', 'func_dict', 'func_defaults')
    for attr_name in re_attrs:
        setattr(old_func, attr_name, getattr(new_func, attr_name, None))
    if not is_closure:
        old_cell_nums = len(old_func.func_closure) if old_func.func_closure else 0
        new_cell_nums = len(new_func.func_closure) if new_func.func_closure else 0
        if new_cell_nums and new_cell_nums == old_cell_nums:
            for idx, cell in enumerate(old_func.func_closure):
                if inspect.isfunction(cell.cell_contents):
                    do_replace_func(new_func.func_closure[idx].cell_contents, cell.cell_contents, True)

上面仅仅对含有闭包的情况进行了简单处理,关于闭包以及cell object相关的介绍可以参考一下我的另一篇博文:理解Python闭包概念.

4.小节

上面完整介绍了基于函数热更的原理以及其核心的地方。考虑到python代码的语法很灵活,要想实际应用于项目中,还有很多要完善的地方。而且热更对运行时代码的更新能力有限,重大的修改还是需要重启程序的。就好比一艘出海的轮船,热更仅仅可以处理一些零件的替换和修复工作,如果有重大的问题,比如船的引擎无法提供动力,那还是要返厂重修才能重新起航的:-)。

限于篇幅先介绍到这里,有问题欢迎一起讨论学习。

posted @ 2018-12-30 17:30  alpha_panda  阅读(7211)  评论(0编辑  收藏  举报