热更新即在不重启进程或者不离开Python interpreter的情况下使得被编辑之后的python源码能够直接生效并按照预期被执行新代码。平常开发中,热更能极大提高程序开发和调试的效率,在修复线上bug中更是扮演重要的角色。但是要想实现一个理想可靠的热更模块又非常的困难。
1.基于reload
reload作为python官方提供的module更新方式,有一定作用,但是很大程度上并不能满足热更的需求。
先来看一下下面的问题:
>>> importsys, math>>>reload(math)
>>> sys.modules.pop('math')
>>> __import__('math')
>>>reload(math)
Traceback (most recent call last):
File"", line 1, in reload(math)
ImportError: reload(): module mathnot insys.modules>>> sys.modules.get('math')
>>> id(math), id(sys.modules.get('math'))
(45429424, 45540272)
函数 __import__ 会在import声明中被调用。import导入一个模块分两步:
find a module, and initialize it if necessary;
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 -*-
importtime, os, sysimporthotfix#r = hotfix.gl_var
#@singleton
classReloadMgr(object):
to_be_reload= ('hotfix',)
check_interval= 1.0
def __init__(self):
self._mod_mtime= dict(map(lambdamod_name: (mod_name, self.get_mtime(mod_name)), self.to_be_reload))defpolling(self):whileTrue:
time.sleep(1)
self._do_reload()def_do_reload(self):for re_mod inself.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)
@staticmethoddefget_mtime( mod_name):
ld_mod=sys.modules.get(mod_name)
file= getattr(ld_mod, '__file__', None)ifos.path.isfile(file):
file= file[:-1] if file[-4:] in ('.pyc', '.pyo') elsefileif file.endswith('.py'):returnos.stat(file).st_mtimereturnNoneif __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 importtime3 #import cnblogs.alpha_panda
4
5 cnt =06
7 deftick():8 globalcnt9 print __name__, cnt10 cnt += 1
11
12 defstart_main_loop():13 frame_time = 1
14 whileTrue:15 time.sleep(frame_time)16 tick()17
18 defstart_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 importos, sys4 importthreading, time, subprocess5 importMainProgram6
7 classChecker():8 def __init__(self):9 self._main_process =None10 self._check_interval = 1.0
11 self._exclude_mod = (__name__, )12 self._entry_program = r'./MainProgram.py'
13 self._mod_mtime = dict(map(lambdamod_name: (mod_name, self.get_mtime(mod_name)), sys.modules.iterkeys()))14 self._start_time =015
16 defstart(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 whileTrue:29 try:30 self._do_check()31 exceptKeyboardInterrupt:32 sys.exit(1)33
34 def_do_check(self):35 sys.stdout.flush()36 time.sleep(self._check_interval)37 ifself._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 insys.modules.iterkeys():45 if mod_name inself._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 returnTrue54 returnFalse55
56 @staticmethod57 defget_mtime( mod_name):58 ld_mod =sys.modules.get(mod_name)59 file = getattr(ld_mod, '__file__', None)60 if file andos.path.isfile(file):61 file = file[:-1] if file[-4:] in ('.pyc', '.pyo') elsefile62 if file.endswith('.py'):63 returnos.stat(file).st_mtime64 returnNone65
66 if __name__ == '__main__':67 print 'Enter entry point...'
68 check =Checker()69 check.start()70 print 'Entry Exit!'
View Code
./Reloader.py
1 defset_sentry():2 whileTrue: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_code9 if exit_code != 5666:10 #非文件修改导致的程序异常退出,没必要进行重启操作
11 print 'main program exit code: %d' %exit_code12 break
13
14 if __name__ == '__main__':15 try:16 set_sentry()17 exceptKeyboardInterrupt:18 sys.exit(1)
View Code
运行Reloader.py,然后在编辑器中修改mainProgram.py,结果如下:
====== restart main program... =====Enter entry point...
programisrunning...
MainProgram 0
MainProgram1MainProgram2MainProgram3MainProgram4MainProgram5The elapsed time:6.000sub_process exit code:5666
====== restart main program... =====Enter entry point...
programisrunning...
MainProgram 0
MainProgram100MainProgram200MainProgram300[Cancelled]
View Code
这其中的主要涉及的问题如下:
检测程序和主程序要分别位于不同进程/线程,并且要能共享进程资源;
检测程序主动退出时,执行主程序的线程要关闭掉(注意:python threading没有提供直接kill线程的接口);
以上问题决定了检测程序和主程序要分别以子进程及其创建的线程的方式运行。
上面的程序中并没有通过遍历工程目录的所有文件的改动状况来重启程序,而是只检测已经被加载到内存中的模块,避免修改暂时没有被使用的文件导致错误的重启。
这个例子仅仅是为了展示一种思路,将线程设置为守护线程以强迫其随着创建进程的结束而退出的做法可能导致资源没有正确释放。
但这种方式本质上并不是热更,也没有保留程序的执行状态,可以看做是一个自动化重启的工具。
3.基于函数替换
下面我们从简单到深入一步步的说明函数替换的热更原理。
3.1 __dict__ vs attrs
先来看一个简例:
classFoo(object):
STA_MEM= 'sta_member variable'@staticmethoddefsta_func():print 'static_func'@classmethoddefcls_func(cls):print 'cls_func'deffunc(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 incomp:for func in(attr_func, dic_func):printfunc, type(func), id(func), inspect.ismethod(func), inspect.isfunction(func), isinstance(func, classmethod), isinstance(func, staticmethod)
看一下比较结果:
40923824 False True False False 40873104False False False True> 40885944True False False False 40873296False False True False 40886024True False False False 40926064 False True False False
可以看到Foo.func和Foo.__dict__['func']获取的并不是同一个对象,类型也不同。
简单可以理解为对于类类型,__dict__中包含的是类的namespace。里面是原生的函数定义,而通过点运算符得到的是类的属性。
3.2 运行时替换对象成员函数
为了便于说明如何在程序运行时替换函数,下面刻意设计的一个简单的例子:
./hotfix.py
#-*- coding:utf-8 -*-
gl_var =0classFoo(object):def __init__(self):
self.cur_mod= __name__
defbar(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)
importhotfixif __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
Thisis Foo member func bar, self.cur_mod =hotfix
hotfix gl_var=0
Thisis Foo member func bar, self.cur_mod =hotfix
hotfix gl_var=0
Thisis Foo member func bar, self.cur_mod = __main__
####### 修改hotfix.Foo.bar函数的定义 #######
1After Modified! Thisis Foo member func bar, self.cur_mod =hotfix
hotfix gl_var=0
Thisis Foo member func bar, self.cur_mod = __main__
View Code
上面的结果说明修改hotfix.Foo.bar的定义并reload之后,新定义的函数对于新建的对象是生效的,但是对于已经存在的对象reloader.foo并不生效。下面添加函数替换:
1 importhotfix2
3 defreload_with_func_replace():4 old_cls =hotfix.Foo5 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
Thisis Foo member func bar, self.cur_mod =hotfix
hotfix gl_var=0
Thisis Foo member func bar, self.cur_mod =hotfix
hotfix gl_var=0
Thisis Foo member func bar, self.cur_mod = __main__
1After Modified! Thisis Foo member func bar, self.cur_mod =hotfix
hotfix gl_var=0
After Modified! Thisis 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 defdo_replace_func(new_func, old_func):4 #暂时不支持closure的处理
5 re_attrs = ('func_doc', 'func_code', 'func_dict', 'func_defaults')6 for attr_name inre_attrs:7 setattr(old_func, attr_name, getattr(new_func, attr_name, None))8
9 defupdate_type(cls_name, old_mod, new_mod, new_cls):10 old_cls =getattr(old_mod, cls_name, None)11 ifold_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 notold_attr:15 setattr(old_cls, name, new_attr)16 continue
17 if inspect.isfunction(new_attr) andinspect.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 defreload_with_func_replace():23 for mod_name inRELOAD_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 ininspect.getmembers(new_mod):28 if new_attr is not type andisinstance(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 inre_attrs:
setattr(old_func, attr_name, getattr(new_func, attr_name, None))if notis_closure:
old_cell_nums= len(old_func.func_closure) if old_func.func_closure else0
new_cell_nums= len(new_func.func_closure) if new_func.func_closure else0if new_cell_nums and new_cell_nums ==old_cell_nums:for idx, cell inenumerate(old_func.func_closure):ifinspect.isfunction(cell.cell_contents):
do_replace_func(new_func.func_closure[idx].cell_contents, cell.cell_contents, True)
上面仅仅对含有闭包的情况进行了简单处理,关于闭包以及cell object相关的介绍可以参考一下我的另一篇博文:理解Python闭包概念.
4.小节
上面完整介绍了基于函数热更的原理以及其核心的地方。考虑到python代码的语法很灵活,要想实际应用于项目中,还有很多要完善的地方。而且热更对运行时代码的更新能力有限,重大的修改还是需要重启程序的。就好比一艘出海的轮船,热更仅仅可以处理一些零件的替换和修复工作,如果有重大的问题,比如船的引擎无法提供动力,那还是要返厂重修才能重新起航的:-)。
限于篇幅先介绍到这里,有问题欢迎一起讨论学习。