C++回调Python的技术手段

李帅
原创 1570       2019-03-21  

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 addr<->lineno 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 (<code object func at 0x00000000009F5D20, file "test.py", line 2>)  

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 <string>  

5. #include <boost/python.hpp>  

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 <iostream>  

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 <string>  

5. #include <boost/python.hpp>  

6. #include <boost/thread.hpp>  

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 <iostream>  

3.   

4. boost::mutex the_mutex;  

5. boost::function0<void> 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的实现原理能有所了解,希望能对大家的学习和工作能有所帮助。

恒生技术之眼原创文章,未经授权禁止转载。详情见转载须知

联系我们

恒 生 技 术 之 眼