1、目标
最开始,我的目标需求是在slipcover的基础上,增加对函数的插桩,也就是在每个def函数之后插入一条语句。
那slipcover又是什么东西呢?它是一个python的动态插桩覆盖率统计工具,它会在加载python模块的时候,自定义一个loader,然后在load的过程中,对python字节码进行修改。
当程序运行的时候,如果是已加载的模块它会直接使用插桩后的模块,对于需要新加载的模块,就进行修改插桩并返回。
同时我们的测试过程会设计到多进程,其实在python中,使用mutilprocessing的process创建的子进程会继承父进程已加载的模块,因为我们都是在linux系统上进行的试验。
当你使用Python的
multiprocessing.Process类来创建子进程,是否会继承父进程中加载的模块取决于操作系统及其'fork
模式
在 Unix-like 系统上
在 Unix-like 系统(如Linux和macOS)上,Python的’multiprocessing’默认使用’fork()系统调用来创建子进程。在·fork()调用之后,子进程会继承父进程的整个内存空间的副本,包括所有已加载的模块。这意味着,使用你提供的代码片段创建的子进程会“看到”所有在'Process
对象创建之前由父进程加载的模块。这种行为使得在子进程启动时无需重新加载这些模块。
所以,创建子进程之后,并不需要重新设置类加载器,直接在父进程中创建子进程运行测试用例即可。
在meta_path中添加一个模块加载器,这个加载器在加载模块时会进行字节码插桩
sys.meta_path.insert(0, SlipcoverMetaPathFinder(CoveragePython.sci, CoveragePython.file_matcher, False))
2、思路
我的初步想法也是参考slipcover本身来进行的;
- 首先,就是通过修改py文件本身的ast树,在每个def函数开始的位置,插入一个赋值语句,为了后续查找它的字节偏移来保证插桩;
- 然后,通过dis.dis获取字节码偏移,找到对应的赋值语句的偏移,然后再在对应位置进行插桩,至此结束
3、问题
经过上面的实现,我发现覆盖率为0。
具体来说,在import的时候,这里是可以统计到覆盖率的。经过调研,发现import其实是未加载的字节码,也是会执行代码的,那它具体会执行哪些代码呢?实际上是那些顶层代码,就是一般写在py文件函数外部的代码,比如说一些初始变量设置。
也就是说我的插桩逻辑只能统计这部分的覆盖,而在实际运行的过程中,一个覆盖都没有。
这里就不禁思考是不是插桩的位置出问题了呢?
经过一系列的对比(插桩前后的字节码对比,以及slipcover本来的插桩逻辑和我的逻辑)
ast树修改这块是没问题的;
问题出在第二步,插桩里面。
终于发现了问题所在:代码(types.CodeType)其实包含了两部分,一部分是顶层代码(也就是我前面提到的,在类加载会执行的代码),一部分是内部的其他函数对象(types.CodeType.co_consts)
那我原来为什么插桩插错了呢?
因为我原来就仅仅对顶层代码进行了处理,并没有考虑内部的其他函数对象,导致我插桩的内容都是在函数外层,且slipcover本身是行覆盖(它会在每一行的前面插入自定义的c代码),我基于它的实现就也插在了def的前面。那这就导致了后续执行的时候根本没有覆盖率。
总结:就是我插桩的代码只会执行一次,因为在外部,属于类加载会运行的代码,那么后续执行api的时候,100%不会执行到自定义的插桩代码
4、解决
# handle functions-within-functions
for i, c in enumerate(co.co_consts):
if isinstance(c, types.CodeType):
ed.set_const(i, self.instrument(c, co))
上面的就是原本slipcover处理py文件内部的函数对象的代码,就是需要对co.co_consts进行处理,这里才是我们真正需要插桩的位置。
所以就在这一块去处理,使用原来的逻辑即可。
def lfl_instrument(self, co: types.CodeType, modify_ast: ast.AST) -> types.CodeType:
# 实际上,我是需要在code对象中进行插桩,也就是co的co_consts
ed = bc.Editor(co)
for i, c in enumerate(co.co_consts):
if isinstance(c, types.CodeType):
ed.set_const(i, self.lfl_instrument_pro(c, modify_ast))
new_code = ed.finish()
return new_code
5、例子分析
这里给出py字节码的一个例子,表示我的分析过程。
1 0 LOAD_CONST 0 (0)
2 LOAD_CONST 1 (None)
4 IMPORT_NAME 0 (os)
6 STORE_NAME 0 (os)
3 8 LOAD_CONST 2 (1)
10 STORE_NAME 1 (a)
4 12 LOAD_CONST 2 (1)
14 STORE_NAME 2 (b)
7 16 LOAD_CONST 3 (<code object add at 0x7fdb4ab0c450, file "/root/src/test.py", line 7>)
18 LOAD_CONST 4 ('add')
20 MAKE_FUNCTION 0
22 STORE_NAME 3 (add)
11 24 LOAD_CONST 5 (<code object subs at 0x7fdb4ab0c500, file "/root/src/test.py", line 11>)
26 LOAD_CONST 6 ('subs')
28 MAKE_FUNCTION 0
30 STORE_NAME 4 (subs)
32 LOAD_CONST 1 (None)
34 RETURN_VALUE
Disassembly of <code object add at 0x7fdb4ab0c450, file "/root/src/test.py", line 7>:
7 0 LOAD_CONST 1 ('add')
2 STORE_FAST 2 (_LFL_FUNC_NAME)
8 4 LOAD_GLOBAL 0 (print)
6 LOAD_CONST 2 ('yes done')
8 CALL_FUNCTION 1
10 POP_TOP
9 12 LOAD_FAST 0 (a)
14 LOAD_FAST 1 (b)
16 BINARY_ADD
18 RETURN_VALUE
Disassembly of <code object subs at 0x7fdb4ab0c500, file "/root/src/test.py", line 11>:
11 0 LOAD_CONST 1 ('subs')
2 STORE_FAST 2 (_LFL_FUNC_NAME)
12 4 LOAD_GLOBAL 0 (print)
6 LOAD_CONST 1 ('subs')
8 CALL_FUNCTION 1
10 POP_TOP
13 12 LOAD_FAST 0 (a)
14 LOAD_FAST 1 (b)
16 BINARY_SUBTRACT
18 RETURN_VALUE
从上面的字节码可以看到,每个函数都有自己单独的字节偏移,我们其实就是需要在这个函数对象内部进行插桩。
当遍历到函数对象的时候才进行处理即可。
而且每个函数对象内部的字节码偏移都是从0开始的,也就是说每个函数对象和外部代码对象都是互相不影响的。(字节偏移为每个函数或者代码内部的局部设计,不会影响其他函数或者全局代码,这样每个函数的编译、优化和执行就可以独立进行)