C++回调Python的技术手段
上一篇文章《C++和Python混合编程的利器》中着重介绍了如何利用Boost Python库来实现C++和Python交互过程中的参数传递。本文将深入到Python源码并结合Python的运行机制,来分析C++回调Python的基本原理。如果对实现原理不感兴趣的同学,可以直接跳至下文中“Boost Python实现C++同步/异步回调Python的Demo” 章节,通过Demo来学习如何实现C++回调Python的方法。
Python解释器运行原理
我们都知道,使用C/C++之类的编译性语言编写的程序,是需要从源文件转换成计算机使用的机器语言,经过链接器链接之后形成了二进制的可执行文件。运行该程序的时候,就可以把二进制程序从硬盘载入到内存中运行。但是对于Python而言,Python源码不需要编译成二进制代码,它可以直接从源代码运行程序。当运行Python程序的时候,Python解释器将源代码转换为字节码,然后再由Python解释器来一条一条执行字节码指令,从而完成程序的执行。
Python编译器和字节码文件
字节码是Python编译器编译 (.py文件)生成的,生成后字节码文件格式为:.pyc,它在Python解释器中对应的是PyCodeObject对象。Python源码中对此对象定义如下:
1. /* Bytecode object */
2. typedef struct {
3. PyObject_HEAD
4. int co_argcount; /* #arguments, except *args */
5. int co_kwonlyargcount; /* #keyword only arguments */
6. int co_nlocals; /* #local variables */
7. int co_stacksize; /* #entries needed for evaluation stack */
8. int co_flags; /* CO_..., see below */
9. PyObject *co_code; /* instruction opcodes */
10. PyObject *co_consts; /* list (constants used) */
11. PyObject *co_names; /* list of strings (names used) */
12. PyObject *co_varnames; /* tuple of strings (local variable names) */
13. PyObject *co_freevars; /* tuple of strings (free variable names) */
14. PyObject *co_cellvars; /* tuple of strings (cell variable names) */
15. unsigned char *co_cell2arg; /* Maps cell vars which are arguments. */
16. PyObject *co_filename; /* unicode (where it was loaded from) */
17. PyObject *co_name; /* unicode (name, for reference) */
18. int co_firstlineno; /* first source line number */
19. PyObject *co_lnotab; /* string (encoding addrlineno mapping) See
20. Objects/lnotab_notes.txt for details. */
21. void *co_zombieframe; /* for optimization only (see frameobject.c) */
22. PyObject *co_weakreflist; /* to support weakrefs to code objects */
23. } PyCodeObject;
要了解PyCodeObject的作用,首先必须要清楚PyCodeObject结构体定义中的每一个域都表示什么含义,表1列出了PyCodeObject对象中各个域的具体意义。
字段
类型
说明
PyObject_HEAD
PyObject
PyObject_HEAD宏 定义了每个 PyObject 的初始段
co_argcount
int
代码块的位置参数的个数
co_kwonlyargcount
int
代码块的关键参数的个数
co_nlocals
int
代码块中局部变量的个数
co_stacksize
int
执行该代码块需要的栈空间
co_flags
int
co_code
PyObject *
代码块编译后的字节码指令序列,PyStringObject的形式存在
co_consts
PyObject *
代码块中所有的常量
co_names
PyObject *
代码块中所有的符号
co_varnames
PyObject *
代码块中所有的局部变量名
co_freevars
PyObject *
实现闭包使用的变量
co_cellvars
PyObject *
代码块中嵌套函数所引用的所有变量名
co_cell2arg
char *
映射单元的参数
co_filename
PyObject *
代码块对应py文件的完整路径
co_name
PyObject *
代码块的名字,通常是函数名或类名
co_firstlineno
int
代码块在对应py文件中的起始行
co_lnotab
PyObject *
字节码指令与py文件中代码行的对应关系
co_zombieframe
void *
优化选项,编译优化时使用
co_weakreflist
PyObject *
支持weakrefs模块的代码对象
PyCodeObject对象包含了py文件中的代码经过编译后得到的字节码序列。Python会将这些字节码序列和PyCodeObject对象一起存储在pyc文件中。
可能细心的读者会有疑问:执行脚本时,并不一定会生成pyc文件,按照本文前面的解释,py脚本运行之前必须经过编译器进行编译,既然编译了,就会产生pyc文件。那为什么我在执行脚本时候,有时候并不会产生pyc文件呢?
Python在通过import对moudle进行动态加载时,如果没有找到对应的pyc和dll文件,就会在py文件的
基础上自动创建pyc文件,也可以显示调用compile把py文件编译成pyc文件。由于篇幅限制,pyc文件的创建和更新机制的细节问题就不再赘述。只需要明白pyc的自动生成的机制就行了。
解析Python字节码(pyc)文件
介绍了PyCodeObject和pyc以及它们之间的关系,下面我们利用Python dis库来解析pyc文件,通过更底层的指令集来分析Python处理类/函数/变量的实现机制:
首先,创建test.py,脚本中只有简单的print函数。
1. #test.py
2. print("Hello, world")
然后,我们创建demo.py,先把test脚本编译成pyc文件,然后利用dis库解析生成的pyc文件。
1. #demo.py
2. s=open('test.py').read()
3. co=compile(s,'test.py','exec')
4. import dis
5. dis.dis(co)
最后,我们执行demo.py,会在控制台输出如下信息:
第一列表示以下几个指令在py文件中的行号;
第二列是该指令在指令序列(PyCodeObject结构体)中的偏移量;
第三列是指令opcode的名称,分为有操作数和无操作数两种,opcode在指令序列中是一个字节的整数;这里对上面用到的指令序列做个简单的说明:
LOAD_NAME
将关联的值co_names[namei]压栈
LOAD_CONST
将关联的值co_consts[consti]压栈。
CALL_FUNCTION
调用一个函数。
POP_TOP
删除顶部堆栈(TOS)项目。
RETURN_VALUE
将TOS返回给函数的调用者。
“字节码”,是指令以字节为单位,1个字节最多只能表示256个不同的字节码指令。实际上Python只用了101条字节码指令,我们这里只列举了其中的5条,如果对Python指令集感兴趣,可以访问网站https://docs.python.org/3/library/dis.html,里面详细罗列了Python所有指令。
第四列是操作数oparg,在指令序列中占两个字节,基本都是co_consts或者co_names的下标;
第五列带括号的是操作数说明。
Python解释器中的函数机制
了解了Python编译的过程,并通过对产生的pyc文件进行解析,明白了pyc文件都包含了哪些内容。但Python的解释器才是Python的核心,在py文件被编译器编译为字节码指令序列后,Python解释器就接管了整个工作。Python解释器从编译PyCodeObject对象中依次读入每一条字节码指令,并在上下文环境中执行这条字节码指令。Python的解释器实际上是在模拟X86的系统中运行可执行文件的过程。这就必须了解X86下函数调用及栈帧原理,当子函数调用时,调用者与被调用者的栈帧结构如下图所示:
在子函数调用时,执行的操作有:
1) 父函数将调用参数从后向前压栈
2) 将返回地址压栈保存
3) 跳转到子函数起始地址执行
4) 子函数将父函数栈帧起始地址(%rpb) 压栈
5) 将%rbp 的值设置为当前 %rsp 的值,即将 %rbp 指向子函数栈帧的起始地址。
上述过程中,保存返回地址和跳转到子函数处执行由call 一条指令完成,在call 指令执行完成时,已经进入了子程序中,因而将上一栈帧%rbp 压栈的操作,需要由子程序来完成。函数调用时在汇编层面的指令序列如下:
1. ... # 参数压栈
2. call FUNC # 将返回地址压栈,并跳转到子函数FUNC处执行
3. ... # 函数调用的返回位置
4. FUNC: # 子函数入口
5. pushq %rbp # 保存旧的帧指针,相当于创建新的栈帧
6. movq %rsp, %rbp # 让%rbp指向新栈帧的起始位置
7. subq $N, %rsp # 在新栈帧中预留一些空位,供子程序使用,用(%rsp+K)或(%rbp-K)的形式引用空位
保存返回地址和保存上一栈帧的%rbp 都是为了函数返回时,恢复父函数的栈帧结构。在使用高级语言进行函数调用时,由编译器自动完成上述整个流程。需要注意的是,父函数中进行参数压栈时,顺序是从后向前进行的。但是,这一行为并不是固定的,是依赖于编译器的具体实现的。栈帧间通过esp指针和ebp指针建立了关系,使新的栈帧在结束之后能顺利回到旧的栈帧中。
Python解释器中处理函数调用正是模拟上述X86下的处理过程。当Python在执行环境中,执行函数调用的字节码指令时,会在当前的执行环境之外创建新的执行环境。新的执行环境就对应上图中一个新的栈帧。Python真正执行的时候,解释器实际上执行的并不是一个PyCodeObject对象,而是PyFrameObject。它在Python源码中的定义是:
1. typedef struct _frame {
2. PyObject_VAR_HEAD
3. struct _frame *f_back; /* 执行环境上一个栈帧*/
4. PyCodeObject *f_code; /* PyCodeObject对象*/
5. PyObject *f_builtins; /* builtin名字空间*/
6. PyObject *f_globals; /* global名字空间*/
7. PyObject *f_locals; /* local名字空间*/
8. PyObject **f_valuestack; /* 运行时栈底*/
9. PyObject **f_stacktop; /* 运行时栈顶*/
10. PyObject *f_trace; /* 异常处理对象*/
11. PyObject *f_exc_type, *f_exc_value, *f_exc_traceback;/* 异常信息*/
12. PyObject *f_gen; /* 生成器对象*/
13. int f_lasti; /* 上一个字节码指令偏移*/
14. int f_lineno; /* 源码行号*/
15. PyObject *f_localsplus[1]; /* 动态内存,维护运行环境所需要的空间*/
16. ...
17. } PyFrameObject;
通过f_back域以及X86的函数处理的堆栈原理,我们可以得出如下结论:Python的实际执行过程中,会产生很多堆栈(PyFrameObject)对象,而这些对象会被Python解释器管理起来,形成一条执行环境栈链,而Python解释器正是通过执行栈链的内容来实现函数调用。
Python中万物皆对象,函数也不例外,在Python源码中,函数对象的定义是PyFunctionObject,它与我们上面分析Python编译过程中产生的PyCodeObject对象又是什么关系呢?它们之间又有什么千丝万缕的联系呢?带着这个问题,我们来一起研究下Python中函数对象的实现,以及在函数调用的过程中,Python是如何进行处理的。
函数对象PyCodeObject在Python源码中的定义:
1. typedef struct {
2. PyObject_HEAD
3. PyObject *func_code; /* 对应函数编译后的PyCodeObject对象*/
4. PyObject *func_globals; /* 函数运行时的globals名字空间*/
5. PyObject *func_defaults; /* 默认参数*/
6. PyObject *func_kwdefaults; /* 关键参数*/
7. PyObject *func_closure; /* 用户实现函数闭包*/
8. PyObject *func_doc; /* 函数的文档对象*/
9. PyObject *func_name; /* 函数的名称*/
10. PyObject *func_dict; /* 函数的dict属性*/
11. PyObject *func_weakreflist; /* 弱引用处理对象*/
12. PyObject *func_module; /* 函数的moudle属性*/
13. PyObject *func_annotations; /* 函数注释*/
14. PyObject *func_qualname; /* 限定名*/
15. } PyFunctionObject;
PyCodeObject对象是对py文件的静态编译结果。它包含了这个py文件的静态信息,例如test.py中的print(“Hello World”),print和字符串”Hello World”就是一种静态信息,它们分别存储在PyCodeObject的co_consts,co_names和co_code中。
而PyFunctionObject则不同,它是Python代码在运行时动态产生的,更准确的说,是在执行def语句的时候创建的。PyFunctionObject函数中当然会包括这个函数的静态信息,它是通过func_code进行映射的。除此之外,PyFunctionObject对象还包括了函数执行过程中需要的上下文信息。
PyCodeObject和PyFunctionObject是1:N的关系,比如一个函数多次调用,Python在执行的过程中会创建多个PyFunctionObject对象,每个对象的func_code域都会关联到这个PyCodeObject。
我们通过一个实例,来一起分析下Python处理函数调用的过程。
1) 修改test.py,把print语句使用函数封装起来,代码如下:
1)
1. #test.py
2. def func():
3. print("Hello, world")
4. func()
2) 运行上面示例中的demo.py
1. E:\svn>python demo.py
2. 2 0 LOAD_CONST 0 ()
3. 3 LOAD_CONST 1 ('func')
4. 6 MAKE_FUNCTION 0
5. 9 STORE_NAME 0 (func)
6. 5 12 LOAD_NAME 0 (func)
7. 15 CALL_FUNCTION 0 (0 positional, 0 keyword pair)
8. 18 POP_TOP
9. 19 LOAD_CONST 2 (None)
10. 22 RETURN_VALUE
3) Python执行函数指令的主要过程:
a) 将test.py静态编译结果PyCodeObject压栈
b) 将co_consts[1]的字节码对象func压栈
c) 执行def语句,动态的创建PyFunctionObject对象并压栈
d) 取出co_names,函数对象出栈,并且f_locals[‘func’]=函数对象
e) 将函数对象压栈,实际对应的是f_locals[‘func’]
f) 调用函数,创建新的栈帧,并在新的栈帧上执行
g) 函数返回并出栈
h) 将None加载到堆栈
i) 返回结果
了解到了Python函数机制的实现过程,通过解析Python中如何实现函数的定义和调用,我们不难发现,C++和Python的函数定义和调用的机制并无区别:都是通过创建新栈帧,在新的栈帧上执行代码来实现的。
本文主题:通过Boost Python库来实现C++回调Python的技术,以上原理的介绍,可能有些人看的比较晕,如果能搞清楚我们在Python解释器运行原理中给出的时序图的执行过程,可能会更加容易接受,不过原理型知识并不影响我们学习如何实现C++回调Python的技术。
接下来,让我们转换下风格。先介绍同步/异步回调的技术实现代码,再来分析其实现过程。
Boost Python实现C++同步回调Python的Demo
所谓同步,就是发出一个功能调用时,在没有得到结果之前,该调用不返回或继续执行后续操作。简单来说,同步就是必须一件一件做,等前一件做完了才能做下一件事。
C++ 模块代码:
1. //Test.h
2. #pragma once
3. #define BOOST_PYTHON_STATIC_LIB
4. #include
5. #include
6.
7. using namespace std;
8. using namespace boost::python;
9.
10. //GIL全局锁简化获取用,
11. //用于帮助C++线程获得GIL锁,从而防止python崩溃
12. class PyLock
13. {
14. private:
15. PyGILState_STATE gil_state;
16. public:
17. //在某个函数方法中创建该对象时,获得GIL锁
18. PyLock()
19. {
20. gil_state = PyGILState_Ensure();
21. }
22. //在某个函数完成后销毁该对象时,解放GIL锁
23. ~PyLock()
24. {
25. PyGILState_Release(gil_state);
26. }
27. };
28.
29. int TestCallBack(const string& szParam, object pyCallBack);
1. //Test.cpp
2. #include "Test.h"
3. #include
4.
5. int TestCallBack(const string& szParam, object pyCallBack)
6. {
7. //Python中传递的参数,C++中可以直接使用
8. std::cout << szParam << std::endl;
9. {
10. PyLock _pylock;
11. pyCallBack("Hello Python, I am is C++");
12. }
13. return 0;
14. }
15.
16. //导出的模块名
17. BOOST_PYTHON_MODULE(Test)
18. {
19. //导入时运行,保证先创建GIL
20. PyEval_InitThreads();
21.
22. def("TestCallBack", TestCallBack);
23. }
Python模块代码:
1. from Test import TestCallBack
2.
3. def testCallBack(szParam):
4. print(szParam)
5.
6. TestCallBack("Hello C++, I am is Python", testCallBack)
在Python环境中执行上述代码,结果如下图:
我们对上述代码的几个重要步骤做下分析:
1) C++定义了导出给Python 接口TestCallBack,参数一个是字符串,一个是Python的函数
2) 执行任何Python代码前,都需要先初始化Python解释器的运行环境(PyEval_InitThreads函数)
3) 执行任何Python代码前,都需要先获得Python解释器GIL锁的使用权限,PyLock封装了C API接口并巧妙的利用了C++类的构造/析构的机制来实现GIL锁的获取和释放
Boost Python实现C++异步回调Python的Demo
异步与同步相对,当一个异步过程调用发出后,调用者在没有得到结果之前,就可以继续执行后续操作。当这个调用完成后,一般通过状态、通知和回调来通知调用者。对于异步调用,调用的返回并不受调用者控制。
1. //Test.h
2. #pragma once
3. #define BOOST_PYTHON_STATIC_LIB
4. #include
5. #include
6. #include
7.
8. using namespace std;
9. using namespace boost::python;
10.
11. //GIL全局锁简化获取用,
12. //用于帮助C++线程获得GIL锁,从而防止python崩溃
13. class PyLock
14. {
15. private:
16. PyGILState_STATE gil_state;
17. public:
18. //在某个函数方法中创建该对象时,获得GIL锁
19. PyLock()
20. {
21. gil_state = PyGILState_Ensure();
22. }
23. //在某个函数完成后销毁该对象时,解放GIL锁
24. ~PyLock()
25. {
26. PyGILState_Release(gil_state);
27. }
28. };
29. void RegisterPyCallBack(object pyCallBack);
30. int TestCallBack(const string& szParam);
31. static void TestThread();
32.
33. object g_pyCallBack; //存储Python回调对象
1. #include "Test.h"
2. #include
3.
4. boost::mutex the_mutex;
5. boost::function0 funcPyCallback = boost::bind(&TestThread);
6. boost::thread tRevAsync(funcPyCallback);
7. bool g_bStart = false;
8.
9. void RegisterPyCallBack(object pyCallBack)
10. {
11. boost::mutex::scoped_lock lock(the_mutex);
12. g_pyCallBack = pyCallBack;
13. g_bStart = true;
14. }
15.
16. void TestThread()
17. {
18. while (true)
19. {
20. if (g_bStart)
21. {
22. PyLock _pylock;
23. boost::mutex::scoped_lock lock(the_mutex);
24. g_pyCallBack("Hello Python, I am is Async C++");
25. break;
26. }
27. }
28. }
29.
30. int TestCallBack(const string& szParam)
31. {
32. std::cout << szParam << std::endl;
33. return 0;
34. }
35.
36. //导出的模块名
37. BOOST_PYTHON_MODULE(Test)
38. {
39. //导入时运行,保证先创建GIL
40. PyEval_InitThreads();
41.
42. def("RegisterPyCallBack", RegisterPyCallBack);
43. def("TestCallBack", TestCallBack);
44. }
Python代码:
1. from Test import TestCallBack, RegisterPyCallBack
2.
3. def testCallBack(szParam):
4. print(szParam)
5. RegisterPyCallBack(testCallBack)
6. TestCallBack("Hello C++, I am is Python")
在Python环境中执行上述代码,结果如下图:
通过对比同步和异步回调的代码发现,从Python调用者的角度来看,就只增加了一个注册回调函数的接口。对于C++的开发者来说,回调也没什么大的区别,无非是取回调的地址方式发生了变化而已。
Boost Python库实现C++同步/异步回调Python的过程
以上代码执行整个过程如下:
利用Boost Python实现C++回调Python接口的主要内容就是这些了。我们从Python解释器的运行原理开始,到Python文件的编译和节码(pyc)文件的创建和生成,再到Python解释器中的函数机制,最后通过C++同步/异步回调Python实例来逐步学习。本文希望读者在学习C++回调Python的技术的同时,能够对Python的实现原理能有所了解,希望能对大家的学习和工作能有所帮助。