3

《Python源码剖析》第二部分——Python虚拟机基础

 2 years ago
source link: https://sund.site/posts/python-2/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Python执行环境

在编译过程中,这些包含在Python源代码中的静态信息都会被Python编译器收集起来,编译的结果中包含了字符串,常量值,字节码等在源代码中出现的一切有用的静态信息。在Python运行期间,这些源文件中提供的静态信息最终会被存储在一个运行时的对象中,当Python运行结束后,这个运行时对象中所包含的信息甚至还会被存储在一种文件中。这个对象和文件就是我们这章探索的重点:PyCodeObject对象和pyc文件。

在程序运行期间,编译结果存在于内存的PyCodeObject对象中;而Python结束运行后,编译结果又被保存到了pyc文件中。当下一次运行相同的程序时,Python会根据pyc文件中记录的编译结果直接建立内存中的PyCodeObject对象,而不用再次对源文件进行编译了。

从文章摘录可见,python生成的不是编译后的文件,而是.py文件对应的静态信息——PyCodeObject,这里包括了字节码指令序列、字符串、常量。每个名字空间(类、模块、函数)都对应一个独立的PyCodeObject。(python连编译后的文件里存的都是个对象!)

不被import的py文件不会生成pyc。标准库里有py_compile等方法也可以生成pyc。

import机制 导入某个模块时,先查找对应的pyc,如果没有pyc就生成然后import这个pyc。(所以实际导入的并不是py文件,而是py文件编译后的PyCodeObject)。

PyFrameObject Python程序运行时的「执行环境」。参考操作系统执行可执行文件的过程。Python也是将函数对应的执行环境封装成栈帧的形式加载进内存。

typedef struct _frame {
    PyObject_VAR_HEAD
    struct _frame *f_back;  //执行环境链上的前一个frame
    PyCodeObject *f_code;   //PyCodeObject对象
    PyObject *f_builtins;   //builtin名字空间
    PyObject *f_globals;    //global名字空间
    PyObject *f_locals;     //local名字空间
    PyObject **f_valuestack;    //运行时栈的栈底位置
    PyObject **f_stacktop;      //运行时栈的栈顶位置
    ……
    int f_lasti;        //上一条字节码指令在f_code中的偏移位置
    int f_lineno;       //当前字节码对应的源代码行
    ……
    //动态内存,维护(局部变量+cell对象集合+free对象集合+运行时栈)所需要的空间
    PyObject *f_localsplus[1];  
} PyFrameObject;

Python标准库的sys._getframe()可以动态的在程序执行时获取当前内存中活跃的PyFrameObject信息。

LEGB 规则

即python作用域的查找顺序是local-enclosing-global-buildin。看下面代码:

a = 1

def g():
  print a

def f():
  print a //[1]
  a = 2 //[2]
  print a

g()

代码在[1]处会抛出异常,原因是python在编译阶段就把静态数据(局部变量、全局变量、字节码)放入pyc里,执行到f()里时,查找到a是在local作用域里定义的而不是global里,但是此时local的a还没赋值,所以就会抛出异常。由此可见,python作用域信息是在静态编译时就处理好了的

Python 虚拟机运行框架

运行时环境是一个全局的概念,而执行环境实际就是一个栈帧,是一个与某个Code Block对应的概念。

在PyCodeObject对象的co_code域中保存着字节码指令和字节码指令的参数,Python虚拟机执行字节码指令序列的过程就是从头到尾遍历整个co_code、依次执行字节码指令的过程。

由上文引用可见,python在编译阶段将代码块的字节码保存在PyCodeObject的co_code属性里,然后在执行阶段从头到尾遍历这个co_code属性解读字节码。

Python运行时环境 Python在运行时用PyInterpreterState结构维护进程运行环境,PyThreadState维护线程运行环境,PyFrameObject维护栈帧运行环境,三者是依次包含关系,如下图所示:

Python虚拟机就是一个「软CPU」,动态加载上述三种结构进内存,并模拟操作系统执行过程。程序执行后,先创建各个运行时环境,再将栈帧中的字节码载入,循环遍历解释执行。

Python字节码

i = 1
0   LOAD_CONST   0  (1)
3   STORE_NAME   0  (i)

例如python的一条语句i=1可以解释为下面两行字节码,最左边的第1列数字代表这行字节码在内存中的偏移位置,第2列是字节码的名字(CPU并不关心名字,它只是根据偏移量读出字节码,所以这个名字是方便阅读用的),第3列是字节码的参数,如LOAD_CONST对应的数据在变量f->f_code->co_consts里,0就是这个参数位于f->f_code->co_consts的偏移量。最后一列的括号里是从参数里取到的value。

Python 的异常抛出机制

异常处理的操作都在Python/traceback.c文件里,python每次调用一层函数,就创建改函数对应的PyFrameObject对象来保存函数运行时信息,PythonFrameObject里调用PyEval_EvalFrameEx循环解释字节码,如果抛出异常就创建PyTraceBackObject对象,将对象交给上一层PyFrameObject里的PyTracebackObject组成链表,最后返回最上层PyRun_SimpleFileExFlags函数,该函数调用PyErr_Print遍历PyTraceBackObject链表打印出异常信息。

函数对象的实现

PyFunctionObject是函数对象。在python调用函数时,生成PyFunctionObject对象,该对象的f_global指针用来将外层的全局变量传递给函数内部,然后在ceval.c文件的fast_function里解出PyFunctionObject对象里携带的信息,创建新的PyFrameObject对象(上文说过这个对象是维护运行时环境的),最后调用执行字节码的函数PyEval_EvalFrameEx执行真正函数字节码。

Python执行一段代码需要什么? 从书中描述可见,python执行一段代码需要做几件事:

  • 从源码编译出 PyCodeObject 保存变量和字节码
  • 执行阶段,从PyCodeObject里取出信息交给 PyFrameObject,执行 PyEval_EvalFrameEx 解释字节码
  • 如果遇到函数调用,就把函数对应的代码段从 PyCodeObject 存入 PyFunctionObject 对象,然后把这个函数对象通过参数传给新创建的 PyFrameObject ,在内层空间执行 PyEval_EvalFrameEx 解释字节码
  • 将结果或异常存入 PyFrameObject 的变量( 异常是存入f_blockstack里,外层判断f_blockstack里的数据是被except捕获还是没有捕获而继续下一步操作) 抛给外层

值得注意的是,python在执行阶段,将对函数参数的键值查找,转换为索引查找,即在转换PyCodeObject为PyFrameObject时,将参数信息按位置参数、键参数按照一定顺序存储在f_localsplus变量中,再用索引来查找对应参数,而需要查找键值。这样提高了运行时效率。下图是foo('Rboert', age=5)在内存中的存储形式。

闭包的实现

Python在编译阶段就把函数闭包内层和闭包外层使用的变量存入PyCodeObject中:

  • co_cellvars:通常是一个tuple,保存嵌套的作用域中使用的变量名集合;
  • co_freevars:通常也是一个tuple,保存使用了的外层作用域中的变量名集合。

在执行阶段,PyFrameObject的f_localsplus中也为闭包的变量划分的内存区域,如下图所示:

元类<type type>和其他类的关系如下图:

可调用性(callable) ,只要一个对象对应的class对象中实现了“call”操作(更确切地说,在Python内部的PyTypeObject中,tp_call不为空)那么这个对象就是一个可调用的对象,换句话说,在Python中,所谓“调用”,就是执行对象的type所对应的class对象的tp_call操作。

Descriptor

在PyType_Ready中,Python虚拟机会填充tp_dict,其中与操作名对应的是一个个descriptor 对于一个Python中的对象obj,如果obj.__ class__对应的class对象中存在__get__、__set__和__delete__三种操作,那么obj就可称为Python一个descriptor。

如果细分,那么descriptor还可分为如下两种:

  1. data descriptor : type中定义了__get__和__set__的descriptor;
  2. non data descriptor : type中只定义了__get__的descriptor。 在Python虚拟机访问instance对象的属性时,descriptor的一个作用是影响Python虚拟机对属性的选择。从PyObject_GenericGetAttr的伪代码可以看出,Python虚拟机会在instance对象自身的__dict__中寻找属性,也会在instance对象对应的class对象的mro列表中寻找
  1. Python虚拟机按照instance属性、class属性的顺序选择属性,即instance属性优先于class属性;
  2. 如果在class属性中发现同名的data descriptor,那么该descriptor会优先于instance属性被Python虚拟机选择

引申:Python 黑魔法 Descriptor (描述器)

Bound Method和Unbound Method

假设有下面两种对类方法的调用:

class A(object):
    def f(self):
        pass

a = A()
# [1]
a.f()  

# [2]
A.f(a)

# [3]
func = a.f
func()

在代码[1]里,实例a调用类方法f,python底层会自动完成实例a和类方法f之间的绑定动作(调用func_ descr_get(A.f, a, A),将实例地址和函数对象PyFunctionObject封装到一个PyMethodObject),而代码[2]里直接通过A调用,则f为非绑定的PyMethodObject,里面没有实例信息,需要传入a。

比较绑定方法与非绑定方法可知,通过[1]的方式每次都要绑定一次实例,开销非常大,下图比较的是[1]和[3]两种方式,绑定操作的执行次数。

结论: 调用类实例绑定的方法时,如果方法执行次数非常多,最好将方法赋值给一个变量,防止重复绑定增加开销


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK