zoukankan      html  css  js  c++  java
  • 《python解释器源码剖析》第9章--python虚拟机框架

    9.0 序

    下面我们就来剖析python运行字节码的原理,我们知道python虚拟机是python的核心,在源代码被编译成字节码序列之后,就将有python的虚拟机接手整个工作。python虚拟机会从编译得到的PyCodeObject对象中一次读取每一条字节码指令,并在当前的上下文中去执行,最终执行完所有的字节码。

    9.1 python虚拟机的执行环境

    python的虚拟机实际上是在模拟操作系统运行可执行文件的过程,我们先来看看在一台普通的x86的机器上,可执行文件是以什么方式运行的。在这里主要关注运行时栈的栈帧,如果所示:

    图中的运行时栈的情形,可以看成是如下c代码运行时的情形

    #include <stdio.h>
    void f(int a, int b)
    {
        printf("a = %d, b = %d
    ", a, b);
    }
    
    void g()
    {
        f(1, 2);
    }
    
    int main()
    {
        g();
    }
    

    当程序进入到函数f时,那么显然调用者的帧就是函数g的栈帧,而当前帧则是f的栈帧。解释一下,栈是先入后出的数据结构,从栈顶到栈底地址是增大的。对于一个函数而言,其所有对局部变量的操作都在自己的栈帧中完成,而调用函数的时候则会为调用的函数创建新的栈帧。

    在上图中,我们看到运行时栈的地址是从高地址向低地址延伸的。当在函数g中调用函数f的时候,系统就会在地址空间中,于g的栈帧之后创建f的栈帧。当然在函数调用的时候,系统会保存上一个栈帧的栈指针(esp)和帧指针(ebp)。当函数的调用完成时,系统就又会把esp和ebp的值恢复为创建f栈帧之前的值,这样程序的流程就又回到了g函数中,当然程序的运行空间则也又回到了函数g的栈帧中,这就是可执行文件在x86机器上的运行原理

    上一章我们说python源代码经过编译之后,所有字节码指令以及其他静态信息都存储在PyCodeObject当中,那么是不是意味着python虚拟机就在PyCodeObject对象上进行所有的动作呢?其实不能给出唯一的答案,因为尽管PyCodeObject包含了关键的字节码指令以及静态信息,但是有一个东西,是没有包含、也不可能包含的,就是程序运行的动态信息--执行环境。

    var = "satori"
    
    def f():
    	var = 666
        print(var)
    
    f()
    print(var)
    

    首先代码当中出现了两个print(var),它们的字节码指令是相同的,但是执行的效果显然却是不同的,这样的结果正是执行环境的不同所产生的。因为环境的不同,var的值也是不同的。因此同一个符号在不同环境中对应不同的类型、不同的值,必须在运行时进行动态地捕捉和维护,这些信息是不可能在PyCodeObject对象中被静态的存储的。

    然而可能有人发现了,这里的执行环境和我们之前提到过的命名空间似乎比较类似。事实上确实如此,但是它们并不是同一个东西,实际上命名空间仅仅是执行环境的一部分,除了命名空间,在执行环境中,还包含了其他的一些信息。

    因此对于上面代码,我们可以大致描述一下流程:

    • 当python在执行第一条语句时,已经创建了一个执行环境,假设叫做A
    • 所有的字节码都会在这个环境中执行,python可以从这个环境中获取变量的值,也可以修改。
    • 当发生函数调用的时候,python会在执行环境A中调用函数f的字节码指令,会在执行环境A之外重新创建一个执行环境B
    • 在环境B中也有一个名字为var的对象,但是由于环境的不同,var也不同。两个人都叫小明,但一个是北京的、一个是上海的,所以这两者没什么关系
    • 一旦当函数f的字节码指令执行完毕,会将当前f()的栈帧销毁(也可以保留下来),再回到调用者的栈帧中来。就像是递归一样,每当调用函数就会创建一个栈帧,一层一层创建,一层一层返回。

    所以python在运行时的时候,执行的实际上不是PyCodeObject对象,而是另一个我们一直说的,栈帧对象(PyFrameObject),从名字也能看出来,这个栈帧也是一个对象。

    9.1.1 Python源码中的PyFrameObject

    对于python而言,PyFrameObject可不仅仅只是类似于x86机器上看到的那个简简单单的栈帧,python中的PyFrameObject实际上包含了更多的信息。

    typedef struct _frame {
        PyObject_VAR_HEAD  // 可变对象的头部信息
        struct _frame *f_back;      /* 上一级栈帧 */
        PyCodeObject *f_code;       /* PyCodeObject对象 */
        PyObject *f_builtins;       /* builtin命名空间,一个PyDictObject对象 */
        PyObject *f_globals;        /* global命名空间,一个PyDictObject对象 */
        PyObject *f_locals;         /* local命名空间,一个PyDictObject对象  */
        PyObject **f_valuestack;    /* 运行时的栈底位置 */
        
        PyObject **f_stacktop;  // 运行时的栈顶位置
        PyObject *f_trace;          /* 回溯函数,打印异常栈 */
        char f_trace_lines;         /* 是否触发每一行的回溯事件 */
        char f_trace_opcodes;       /* 是否触发每一个操作码的回溯事件 */
    
        /* Borrowed reference to a generator, or NULL */
        PyObject *f_gen; //是否是生成器
    
        int f_lasti;                /* 上一条指令在f_code中的偏移量 */
    
        int f_lineno;               /* 当前字节码对应的源代码行 */
        int f_iblock;               /* 当前指令在栈f_blockstack中的索引 */
        char f_executing;           /* 当前栈帧是否仍在执行 */
        PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* 用于try和loop代码块 */
        PyObject *f_localsplus[1];  /* 动态内存,维护局部变量+cell对象集合+free对象集合+运行时栈所需要的空间 */
    } PyFrameObject;
    

    因此我们看到,当执行到字节码的时候,其实会根据当前的字节码创建一个栈帧,也就是PyFrameObject对象,虚拟机执行的实际上是PyFrameObject对象。并且从f_back中可以看出,在python的实际执行过程中,会产生很多PyFrameObject对象,而这些对象会被链接起来,形成一条执行环境链表,这正是x86机器上栈帧之间关系的模拟。在x86机器上,栈帧间通过esp和ebp指针建立了联系,使得新栈帧在结束之后能够顺利的返回到旧栈帧中,而python则是利用f_back来完成这个动作。

    在f_code中存放的是一个待执行的PyCodeObject对象,而接下来的f_builtins、f_globals、f_locals是三个独立的命名空间,在这里我们看到了命名空间和执行环境(即栈帧)之间的关系。代码中我们说了命名空间实际上是维护这变量名和变量值的PyDictObject对象,所以在这三个PyDictObject对象中分别维护了各自name和value的对应关系

    在PyFrameObject的开头,有一个PyObject_VAR_HEAD,表示栈帧是一个边长对象,即每一次创建PyFrameObject对象大小可能是不一样的,那么变动在什么地方呢?首先每一个PyFrameObject对象都维护了一个PyCodeObject对象,而每一个PyCodeObject对象都会对应一个代码块(code block)。在编译一段代码块的时候,会计算这段代码块执行时所需要的栈空间的大小,这个栈空间大小存储在f_stacksize中。而不同的代码块所需要的栈空间是不同的,因此PyFrameObject的开头要有一个PyObject_VAR_HEAD对象。最后其实PyFrameObject里面的内存空间分为两部分,一部分是编译代码块需要的空间,另一部分是计算所需要的空间,我们也称之为"运行时栈"。

    注意:x86机器上执行时的运行时栈是包含了计算所需要的内存空间的,但PyFrameObject对象的运行时栈则只包含计算所需要的内存空间,这一点务必注意

    9.1.2 PyFrameObject中的动态内存空间

    PyFrameObject中有这么一个属性,我们说它是"动态内存,维护局部变量+cell对象集合+free对象集合+运行时栈所需要的空间",因此可以看出这段内存不仅仅使用来给栈使用的,还有别的对象使用。

    PyFrameObject*
    PyFrame_New(PyThreadState *tstate, PyCodeObject *code,
                PyObject *globals, PyObject *locals)
    {	
        //本质上调用了_PyFrame_New_NoTrack
        PyFrameObject *f = _PyFrame_New_NoTrack(tstate, code, globals, locals);
        if (f)
            _PyObject_GC_TRACK(f);
        return f;
    }
    
    
    PyFrameObject* _Py_HOT_FUNCTION
    _PyFrame_New_NoTrack(PyThreadState *tstate, PyCodeObject *code,
                         PyObject *globals, PyObject *locals)
    {	
        //上一级的栈帧
        PyFrameObject *back = tstate->frame;
        //当然的栈帧
        PyFrameObject *f;
        //builtin
        PyObject *builtins;
        //用于循环的int(python中的)或者long(c里面的)
        Py_ssize_t i;
    
    	/*
    	...
    	...
    	...
    	...
    	
    	*/
        else {
            Py_ssize_t extras, ncells, nfrees;
            ncells = PyTuple_GET_SIZE(code->co_cellvars);
            nfrees = PyTuple_GET_SIZE(code->co_freevars);
            //这四部分便构成了PyFrameObject维护的动态内存区,其大小由extras确定
            extras = code->co_stacksize + code->co_nlocals + ncells +
                nfrees;
            
        /*
    	...
    	...
    	...
    	...
    	
    	*/
            f->f_code = code;
            //计算初始化运行时,栈的栈顶,所以没有加上stacksize
            extras = code->co_nlocals + ncells + nfrees;
            //f_valuestack维护运行时栈的栈底
            f->f_valuestack = f->f_localsplus + extras;
            for (i=0; i<extras; i++)
                f->f_localsplus[i] = NULL;
            f->f_locals = NULL;
            f->f_trace = NULL;
        }
        //f_stacktopk维护运行时栈的栈顶
        f->f_stacktop = f->f_valuestack;
        f->f_builtins = builtins;
        Py_XINCREF(back);
        f->f_back = back;
        Py_INCREF(code);
        Py_INCREF(globals);
        f->f_globals = globals;
        /* Most functions have CO_NEWLOCALS and CO_OPTIMIZED set. */
        if ((code->co_flags & (CO_NEWLOCALS | CO_OPTIMIZED)) ==
            (CO_NEWLOCALS | CO_OPTIMIZED))
            ; /* f_locals = NULL; will be set by PyFrame_FastToLocals() */
        else if (code->co_flags & CO_NEWLOCALS) {
            locals = PyDict_New();
            if (locals == NULL) {
                Py_DECREF(f);
                return NULL;
            }
            f->f_locals = locals;
        }
        else {
            if (locals == NULL)
                locals = globals;
            Py_INCREF(locals);
            f->f_locals = locals;
        }
    	
        //设置一些其他属性,返回返回该栈帧
        f->f_lasti = -1;
        f->f_lineno = code->co_firstlineno;
        f->f_iblock = 0;
        f->f_executing = 0;
        f->f_gen = NULL;
        f->f_trace_opcodes = 0;
        f->f_trace_lines = 1;
    
        return f;
    }
    

    可以看到,在创建PyFrameObject对象时,额外申请的"运行时栈"对应的空间有一部分是给PyCodeObject对象中存储的那些局部变量、co_freevars、co_cellvars(co_freevars、co_cellvars是与闭包有关的内容,后面章节会剖析)使用的,而剩下的才是给真正运行时栈使用的

    9.1.3 在python中访问PyFrameObject对象

    关于如何在python中访问PyFrameObject对象,其实我们在上一章节介绍python字节码的时候已经介绍过了,这里再说一次。

    关于获取栈帧,我们可以使用inspect模块

    # -*- coding:utf-8 -*-
    # @Author: WanMingZhu
    # @Date: 2019/11/5 9:50
    import inspect
    
    
    def bar():
        name = "mashiro"
        age = 16
        return foo()
    
    
    def foo():
        name = "satori"
        age = 16
        # 通过inspect.currentframe()可以拿到当前函数的栈帧
        # 其实本质上调用了sys._getframe()
        return inspect.currentframe()
    
    
    # 此时拿到的栈帧就是foo函数的栈帧
    frame = bar()
    print(frame)  # <frame at 0x000001EC70619A40, file 'C:/Users/satori/Desktop/love_minami/a.py', line 17, code foo>
    print(type(frame))  # <class 'frame'>
    # 还记得栈帧的属性吗?
    
    # f_back:上一级栈帧, 不用想肯定是bar
    print(frame.f_back)  # <frame at 0x00000290D2B92DD0, file 'C:/Users/satori/Desktop/love_minami/a.py', line 10, code bar>
    
    # f_code:PyCodeObject对象
    print(frame.f_code)  # <code object foo at 0x0000022F8AF4F030, file "C:/Users/satori/Desktop/love_minami/a.py", line 13>
    
    # f_builtins
    #print(frame.f_builtins)  # 打印的内容非常多
    
    # f_globals:全局变量
    print(frame.f_globals)
    
    # f_locals:局部变量
    print(frame.f_locals)  # {'name': 'satori', 'age': 16}
    print(frame.f_back.f_locals)  # {'name': 'mashiro', 'age': 16}
    
    # f_valuestack
    print(frame.f_lasti)  # 14
    

    异常处理也可以获取到栈帧

    # -*- coding:utf-8 -*-
    # @Author: WanMingZhu
    # @Date: 2019/11/5 9:50
    def foo():
        try:
            1 / 0
        except ZeroDivisionError:
            import sys
            # exc_info返回一个三元组,分别是异常的类型、值、以及traceback
            exc_type, exc_value, exc_tb = sys.exc_info()
            print(exc_type)  # <class 'ZeroDivisionError'>
            print(exc_value)  # division by zer
            print(exc_tb)  # <traceback object at 0x00000135CEFDF6C0>
            # 因为foo是在模块级别、也就是最外成调用了,所以tb_frame是当前函数的栈帧、那么f_back就是整个模块对应的栈帧
            # 我们说每一个PyFrameObject都会维护一个PyCodeObject,换句话说,每一个PyCodeObject都会隶属于一个PyFrameObject
            # 模块、函数、类都会被编译成一个PyCodeObject,在执行的时候肯定会创建栈帧,对于模块来说肯定是位于最外层
            # 那么name = "xxx"这个全局变量会在f_locals里面,这个name = "xxx"不是全局的吗?为什么是f_locals,对于模块的栈帧来说,它就是局部的
            # 而age = 16是在调用foo之后创建的,所以此时模块对应的栈帧的f_locals是没有age这个key的。
            print(exc_tb.tb_frame.f_back.f_locals)  # {..., 'foo': <function foo at 0x000002B1CCAA61F0>, 'name': 'xxx'}
            # 显然模块没有上一级栈帧了,因此在tb_frame.f_back的基础上再次调用f_back返回为None
            print(exc_tb.tb_frame.f_back.f_back)  # None
    
    
    name = "xxx"
    foo()
    age = 16
    

    9.2 名称、作用域、命名空间

    我们在PyFrameObject里面看到了3个独立的命名空间:local、global、builtin。命名空间对于python来说是一个非常重要的概念,整个python虚拟机运行的机制和命名空间有着非常紧密的联系。并且在python中,与命名空间这个概念紧密联系着的还有"名称"、"作用域"这些概念,下面就来剖析这些概念是如何实现的。

    另外,后面将会提到module、class等概念。当然这些会在后面章节才会剖析实现机制,目前只是会提到,只需要知道是干什么的就可以了。当然我相信肯定都知道,如果看解释器源码但却不知道module、class是干啥的,也太·······

    9.2.1 python程序的基础结构--module

    真正生产上的项目不会只有一个py文件,而是由多个py文件组成,一个py文件就称之为一个module。这些module中肯定有一个主module,比如flask程序,最终是通过app.py启动的,app.py中调用了其他的module,那么app.py就是主module。

    为什么要有module,一方面是为了结构划分、不同的功能写在不同的module里,以及实现代码复用,另一方面则是为整个系统划分命名空间。

    一份名字(名称、符号、变量名)就是用于代表某些事物的一个字符序列。在python中,一个标识符就是一个名字,比如变量名、函数名、类名等等,这些都是名字。但是名字的作用不在于名字本身,而是名字背后对应的那个事物。与c语言相比,对于python这种动态语言来说,名字所代表的意义要更大,因为在python中,名字是运行时找到其对应东西的唯一途径

    要使用一个module,必须要先加载。我们可以使用import,在加载的过程中都会执行一个动作,那就是执行module中的代码块。

    # a.py
    a = 1
    print(a)
    
    # b.py
    import a
    print(123)
    # 此时执行b.py,会发现打印如下
    """
    1
    123
    """
    # 我们发现1是先被打印的,因为import a这一步等价于把a里面的代码拿过来执行一遍
    # 然后才会执行b里面的print
    

    要使用一个module,必须要先加载。我们可以使用import,在加载的过程中都会执行一个动作,那就是执行module中的代码块。

    9.2.2 变量赋值与名字空间

    首先我们回想一个赋值语句,比如a = 1。在python中,赋值语句(或者说诸如setattr等具有赋值行为的语句)是一类相当特殊的语句,原因很简单,因为它们会影响命名空间。a = 1,是先创建一个整数对象1,然后将这个对象赋给名字a。同样的def foo()也是一个赋值语句,它的作用是根据内部的函数体创建一个函数对象(后面章节介绍),然后将这个函数对象赋给名字foo,所以函数也相当于普通的变量,本质都是一样的

    我们可以总结出python中赋值语句行为的共同之处:

    • 创建一个对象
    • 将这个对象赋给一个名字

    还记得我们之前说python中的变量是什么吗?对,本质上是一个标签。先创建一个对象,然后再将变量名贴在上面。

    其实除了常见的赋值语句之外, class A:,import os等等都是赋值语句,都遵循赋值语句的行为。什么?你说import也是赋值语句,我咋不信嘞,我们来看看。

    # 对于python解释器来说,
    # import os 本质上是调用 os = __import__("os")
    os = __import__("os")
    print(os.path.abspath(r"D:etc
    edis..docker"))  # D:etcdocker
    # 可以看到本质上还是一个赋值语句嘛
    
    

    因此当我们执行赋值语句的时候,从概念上来讲,我们实际上得到了一个(name, obj)这样的映射关系,其容身之所就是名字空间。而名字空间就是一个PyDictObject对象实现的,这对于映射来说简直再适合不过了,所以dict在python底层也是被大量使用的,因此是经过高度优化的。

    # hanser.py
    def foo():
        pass
    
    a = 1
    
    

    对于这样的一个module,被python加载到内存中,它以一个module对象的形式存在。在module对象中,维护这一个名字空间。而(foo, function object)、(a, 1)这些映射关系就位于module的命名空间中。

    一个对象的命名空间中的所有名字都称之为对象的属性,这是一类可以被赋值的。除了被赋值,还有可以让外界访问其属性的。对于访问属性这一动作,我们称之为属性引用。比如对于a.py,如果在b.py中有import a,print(a.xxx)这样的语句,那么其中的a.xxx就是属性访问。属性访问就是使用另一个空间的名字,一个module对应一个独立的命名空间,在另一个module中要使用别的module的名字,只能通过属性引用的方式访问其命名空间,然后获取名字对应的对象

    显然在module中,命名空间的划分是很清晰的,但是在module的内部,对于命名空间的使用则有另一套不同的规则。

    9.2.3作用域和名字空间

    我们上面提到,约束一旦被创建,就会被放入命名空间中,然后影响程序的行为。在module的内部里面,这样描述也是没错的,但还是不够细致,那就是在module内部,存在这一个可见性的问题。

    a = 1
    
    def foo():
        a = 2
        print(a)  # 2
    
    foo()
    print(a)  # 1
    
    

    我们看到同一个变量名,打印的确实不同的值。这说明两个变量是在不同的命名空间中被创建的,我们知道命名空间本质上是一个字典,如果两者是在同一个命名空间,那么由于字典的key的不重复性,那么当我进行a=2的时候,会把字典里面key为'a'的value给更新掉,但是在外面还是打印为1,这说明,两者所在的不是同一个命名空间。在不同的命名空间,打印的也就自然不是同一个a

    因此在一个module内部是可能存在多个命名空间的,每一个命名空间都与一个作用域相对应。作用域就可以理解为一段程序的正文区域,在这个区域里面定义的变量是有作用的,然而一旦出了这个区域,就无效了。

    对于作用域这个概念,至关重要的是要记住它仅仅是由源程序的文本所决定的。在python中,一个变量在某个位置是否起作用,是由其在文本位置是否唯一决定的。因此,python是具有静态作用域(词法作用域)的,而命名空间就是和作用域对应的动态的东西,一个由程序文本定义的作用域在python运行时就会转化为一个命名空间,一个内存的PyDictObject对象。也就是说,在函数执行f时,会为f创建一个命名空间,这一点在以后剖析函数时会详细介绍。

    位于一个作用域中的代码可以直接访问作用域中出现的名字,所谓"直接访问",就是不通过属性引用的访问修饰符:'.'比如在b.py中访问a.py里面的内容,要通过a.xxx的方式,表示要通过a来获取里面的属性,但是访问b.py自己的内容,则直接xxx即可。

    访问名字这样的行为被称为名字引用,名字引用的规则决定了python程序的行为。还是对于上面的代码,如果我们把函数里面的a=2给删掉,那么显然作用域里面已经没有a这个变量的,那么再执行程序会有什么后果呢?从python层面来看,显然是会寻找外部的a。因此我们可以得到如下结论:

    • 作用域是层层嵌套的,显然是这样,毕竟python虚拟机执行的是PyFrameObject对象,而PyFrameObject对象也是嵌套的,当然还有PyCodeObject
    • 内层的作用域是可以访问外层作用域的
    • 外层作用域无法访问内层作用域,尽管我们没有试,但是想都不用想,如果把外层的a=1个去掉,那么最后面的print(a)铁定报错。因为外部的作用域算是属于顶层了(先不考虑builtin)。
    • 查找元素,会依次从当前作用域向外查找,也就是查找元素对应的作用域是按照从小往大、从里往外的方向前进的,到了最外层还没有,就真没有了(先不考虑builtin)

    9.2.4 LGB规则

    在python中,一个module对应的源文件定义了一个作用域,这个作用域称为global作用域(对应global命名空间);一个函数定义了一个作用域,这个作用域称为local作用域(对应local命名空间);同时python自身还定义了一个最顶层的作用域,也就是builtin作用域(比如:dir、range、open都是builtin里面的)。这三个作用域在python2.2之前就存在了,所以那时候python的作用域规则被称之为LGB规则:名字引用动作沿着local作用域、global作用域、builtin作用域来查找对应的变量。

    • 对于模块级别的作用域来说,globals和locals对应的作用域是一样的,此时f_locals和f_globals是同一个PyDictObject对象

      print(locals() == globals())  # True
      
      
    • 对于函数来说,其locals作用域就是其内部的locals作用域,但是globals作用域和外层的globals是一样的

      def bar():
          def foo1():
              def foo2():
                  def foo3():
                      return globals()
                  return foo3
              return foo2
          return foo1
      
      print(locals() == bar()()()() == globals())  # True
      # 定义了很多层函数,那么不管多少层,其globals()就是外界的globals()
      
      

    9.2.5 LEGB规则

    我们上面说的LGB是针对python2.2之前的,那么python2.2开始,由于引入了嵌套函数,显然最好的方式应该是内层函数找不到应该首先去外层函数找,而不是直接就跑到globals、也就是全局里面找,那么此时的规则就是LEGB。

    a = 1
    
    def foo():
        a = 2
    
        def bar():
            print(a)
        return bar
    
    
    f = foo()
    f()
    """
    2
    """
    
    

    调用f,实际上调用的是bar函数,最终输出的结果是2。如果按照LGB的规则来查找的话。bar函数的作用域没有a、那么应该到全局里面找,打印的应该是1才对。但是我们之前说了,作用域仅仅是由文本决定的,函数bar位于函数foo之内,所以bar函数定义的作用域内嵌与函数foo的作用域之内。换句话说,函数foo的作用域是函数bar的作用域的直接外围作用域,所以首先是从foo作用域里面找,如果没有那么再去全局里面找。

    因此在执行f=foo()的时候,会执行函数foo中的def bar():语句,这个时候python会将a=2与函数bar对应的函数对象捆绑在一起,将捆绑之后的结果返回,这个捆绑起来的整体称之为闭包。

    这里显示的规则就是LEGB,其中E成为enclosing,代表直接外围作用域这个概念。

    9.2.6 global表达式

    有一个很奇怪的问题,最开始学习python的时候,笔者也为此困惑了一段时间,下面我们来看一下。

    a = 1
    
    def foo():
        print(a)
    
    foo()
    """
    1
    """
    
    

    首先这段代码打印1,这显然是没有问题的,但是下面问题来了。

    a = 1
    
    def foo():
        print(a)
        a = 2
    
    foo()
    """
    Traceback (most recent call last):
      File "C:/Users/satori/Desktop/love_minami/a.py", line 8, in <module>
        foo()
      File "C:/Users/satori/Desktop/love_minami/a.py", line 5, in foo
        print(a)
    UnboundLocalError: local variable 'a' referenced before assignment
    """
    
    

    这里我仅仅是在print下面,在当前作用域又新建了一个变量a,结果就告诉我局部变量a在赋值之前就被引用了,这是怎么一回事,相信肯定有人为此困惑。

    弄明白这个错误的根本就在于要深刻理解最内嵌套作用域的规则,这个规则的第一句话就已经解释了:有一个赋值语句所创建的变量在这个赋值语句所在的作用域里都是可见的。因此我们可以得出,虽然a=2这个语句是在print之后才执行的,但是因为它们在同一个作用域里面,所以变量a就是可见的,那么python就知道当前作用域将会存在着一个变量a,但不幸的是,虽然名字是可见的,但是真正将a这个变量创建出来并且赋上值则是在print之后了,因此就会出现局部变量a在赋值之前被引用

    更有趣的东西隐藏在字节码当中,我们可以通过反汇编来查看一下:

    import dis
    
    a = 1
    
    
    def g():
        print(a)
    
    dis.dis(g)
    """
      7           0 LOAD_GLOBAL              0 (print)
                  2 LOAD_GLOBAL              1 (a)
                  4 CALL_FUNCTION            1
                  6 POP_TOP
                  8 LOAD_CONST               0 (None)
                 10 RETURN_VALUE
    """
    
    def f():
        print(a)
        a = 2
    
    dis.dis(f)
    """
     12           0 LOAD_GLOBAL              0 (print)
                  2 LOAD_FAST                0 (a)
                  4 CALL_FUNCTION            1
                  6 POP_TOP
    
     13           8 LOAD_CONST               1 (2)
                 10 STORE_FAST               0 (a)
                 12 LOAD_CONST               0 (None)
                 14 RETURN_VALUE
    """
    
    

    中间的序号代表字节码的偏移量,我们看第二条,g的字节码是LOAD_GLOBAL,意思是在global命名空间中查找,而f的字节码是LOAD_FAST,表示是在local命名空间中查找名字。这说明python采用了静态作用域策略,在编译的时候就已经知道了名字藏身于何处。

    因此上面的例子表明,一旦作用域有了对某个名字的赋值操作,这个名字就会在作用域中可见,就会出现在locals命名空间中,换句话说,就遮蔽了外层作用域中相同的名字。

    但有时我们想要在函数里面修改全局变量呢?当然python也为我们精心准备了global关键字,比如函数内部出现了global a,就表示我后面的a是全局的,你要到global名称空间里面找,不要在local里面找了

    a = 1
    
    
    def foo():
        global a
        a = 2
    
    
    foo()
    print(a)  # 2
    
    
    a = 1
    
    def bar():
        def foo():
            global a
            a = 2
        return foo
    
    bar()()
    print(a)  # 2
    
    

    但是如果外层函数里面也出现了a,我们想找外层函数里面的a而不是全局的a,该怎么办呢?python同样为我们准备了关键字,nonlocal

    a = 1
    
    def bar():
        a = 2
        def foo():
            nonlocal a
            a = "xxx"
        return foo
    
    bar()()
    print(a)  # 1
    # 外界依旧是1
    
    

    9.2.7 属性引用与名称引用

    属性引用实质上也是一种名称引用,其本质都是到命名空间中去查找一个名称所引用的对象。这个就比较简单了,比如a.xxx,就是到a里面去找xxx,这个规则是不受LEGB作用域限制的,就是到a里面查找,有就是有、没有就是没有。

    另外一个名称引用的访问是不能越过自身module的,而且导包的时候,我们说import a本质上就是把a里面的代码拿过来执行一遍,我们来看个例子

    # a.py
    a = 1
    import b
    
    # b.py
    print(a)
    
    

    我们执行a.py实际上是会报错的,会提示变量a没有被定义。可是把b导进来的话,就相当于print(a),而我们上面也定义了啊。显然,即使我们把b导入了进来,但是b.py里面的内容依旧是处于一个module里面,而我们也说了,名称引用是LEGB规则,但是无论如何都无法越过自身的module的,print(a)是在module b里面的,而变量是module a里面的,是不可能跨过module b的作用域访问module a里面的内容的。

    # b.py
    msg = "xxx"
    
    def foo():
        print(msg)
        
        
    # a.py
    msg = "aaa"
    from b import foo
    
    foo()  # xxx
    
    

    执行a.py的时候,打印的是xxx。from b import foo,还是把b.py里面的内容从头到尾执行一遍,对,没错,无论是import、还是from  import,都会把代码全部执行一遍。对于当前的from b import foo会把b.py里面的内容执行一遍,但是导入进来、暴露给a的只有foo,但是这个foo依旧是处于module b这个命名空间里面,所以打印的还是b里面的msg。同理如果,b里面没有msg这个变量,那么只会报错、而不会到a里面找,因为两个module,是无法越过一个module去另一个module里面找的。

    9.3 python虚拟机的运行框架

    当python启动后,首先会进行运行时环境的初始化。注意这里的运行时环境,这与之前剖析的执行环境是不同的概念。运行时环境是一个全局的概念,而执行时环境是一个栈帧,是一个与某个code block相对应的概念。现在不清楚两者的区别不要紧,后面会详细介绍。关于运行时环境的初始化是一个非常复杂的过程,我们后面将用单独的一章进行剖析,这里就假设初始化动作已经完成,我们已经站在了python虚拟机的门槛外面,只需要轻轻推动一下第一张骨牌,整个执行过程就像多米诺骨牌一样,一环扣一环地展开。

    推动第一张骨牌的地方是在一个名叫PyEval_EvalFrameEx的函数中,这个函数实际上就是python的虚拟机的具体实现,接收一个PyFrameObject对象和一个int

    //ceval.c
    PyObject *
    PyEval_EvalFrameEx(PyFrameObject *f, int throwflag)
    {	
        //我们看到这里创建了一个PyThreadState对象,最终调用了该对象下的interp下的eval_frame方法
        //将PyEval_EvalFrameEx里面的参数直接传进去了
        PyThreadState *tstate = PyThreadState_GET();
        return tstate->interp->eval_frame(f, throwflag);
    }
    //PyThreadState_GET是由PyInterpreterState_New创建的
    //pystate.c
    PyInterpreterState *
    PyInterpreterState_New(void)
    {
        PyInterpreterState *interp = (PyInterpreterState *)
                                     PyMem_RawMalloc(sizeof(PyInterpreterState));
    	/*
    	...
    	*/
        interp->eval_frame = _PyEval_EvalFrameDefault; //这里的eval_frame实际上就是_PyEval_EvalFrameDefault
        /*
        ...
        */
        return interp;
    }
    
    
    

    因此最终的关键变成了_PyEval_EvalFrameDefault,这个函数同样在ceva.c下,而这个函数非常的长,有大概三千行左右,是虚拟机运行的核心。

    //不可能把源码全列出来,只列出当前需要解析的
    PyObject* _Py_HOT_FUNCTION
    _PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag)
    {	
        /*
        该函数首先会初始化一些变量,PyFrameObject对象中的PyCodeObject对象包含的信息不用说,还有一个重要的动作就是初始化堆栈的栈顶指针,使其指向f->f_stacktop
        */
        
        co = f->f_code;
        names = co->co_names;
        consts = co->co_consts;
        fastlocals = f->f_localsplus;
        freevars = f->f_localsplus + co->co_nlocals;
        next_instr = first_instr;
        if (f->f_lasti >= 0) {
            assert(f->f_lasti % sizeof(_Py_CODEUNIT) == 0);
            next_instr += f->f_lasti / sizeof(_Py_CODEUNIT) + 1;
        }
        stack_pointer = f->f_stacktop;
        assert(stack_pointer != NULL);
        f->f_stacktop = NULL;       /* remains NULL unless yield suspends frame */
    }
        /*
        PyFrameObject对象中的f_code就是PyCodeObject对象,而PyCodeObject对象里面的co_code域则保存着字节码指令和字节码指令参数,python执行字节码指令序列的过程就是从头到尾遍历整个co_code、依次执行字节码指令的过程。在python的虚拟机中,利用三个变量来完成整个遍历过程。
        首先co_code本质上是一个PyBytesObject对象,而其中的字符数组才是真正有意义的东西。也就是说整个字节码指令序列就是c中一个普普通通的数组。
        因此遍历的过程使用的3个变量都是char *类型的变量
        1.first_instr:永远指向字节码指令序列的开始位置
        2.next_instr:永远指向下一条待执行的字节码指令的位置
        3.f_lasti:指向上一条已经执行过的字节码指令的位置
        */
    
    

    那么这个一步一步的动作是如何完成的呢?我们来看看python虚拟机执行字节码的整体架构,其实就是一个for循环加上一个巨大的switch case结构。

    	why = WHY_NOT;
    	/*
    	...
    	*/
        for (;;) {
    	/*
    	...
    	*/
        fast_next_opcode:
            //获取字节码指令
            f->f_lasti = INSTR_OFFSET();
            /*
            ...
            */
            NEXTOPARG();
            /*
            ...
            */
            if (lltrace) {
                //如果指令需要参数,那么获取指令参数
                if (HAS_ARG(opcode)) {
                    printf("%d: %d, %d
    ",
                           f->f_lasti, opcode, oparg);
                }
                else {
                    printf("%d: %d
    ",
                           f->f_lasti, opcode);
                }
            }
    #endif
    
            switch (opcode) {
    
            TARGET(NOP)
                FAST_DISPATCH();
    
            TARGET(LOAD_FAST) {
                PyObject *value = GETLOCAL(oparg);
                if (value == NULL) {
                    format_exc_check_arg(PyExc_UnboundLocalError,
                                         UNBOUNDLOCAL_ERROR_MSG,
                                         PyTuple_GetItem(co->co_varnames, oparg));
                    goto error;
                }
                Py_INCREF(value);
                PUSH(value);
                FAST_DISPATCH();
            }
    
            PREDICTED(LOAD_CONST);
            /*
            ...
            */
    
    

    注意:上面只是源码的很少很少的一部分,这个函数的代码非常长。如果想看的话可以自行去ceval.c中查看,估计看下去很困难,太特喵的长了。

    但是在这个执行架构中,对字节码一步一步的遍历是通过几个宏来实现的:

    #define INSTR_OFFSET()  
        (sizeof(_Py_CODEUNIT) * (int)(next_instr - first_instr))
    
    #define NEXTOPARG()  do { 
            _Py_CODEUNIT word = *next_instr; 
            opcode = _Py_OPCODE(word); 
            oparg = _Py_OPARG(word); 
            next_instr++; 
        } while (0)
    
    

    在对PyCodeObject对象的分析中我们说过,python的字节码有的是带有参数的,有的是没有参数的,而判断字节码是否带有参数是通过HAS_AGR这个宏来实现的。注意:对于不同的字节码指令,由于存在是否需要指令参数的区别,所以next_instr的位移可以是不同的,但无论如何,next_instr总是指向python下一条要执行的字节码。

    python在获得了一条字节码指令和其需要的参数指令之后,会对字节码利用switch进行判断,根据判断的结果选择不同的case语句,每一条字节码都会对应一个case语句。在case语句中,就是python对字节码指令的实现。

    在成功执行完一条字节码指令和其需要的指令参数之后,python的执行流程会跳转到fast_next_opcode处,或者for循环处,不管如何,python接下来的动作就是获取下一条字节码指令和指令参数,完成对下一条指令的执行。如此一条一条地遍历co_code中包含的所有字节码指令,最终完成了对python程序的执行。

    不过在代码的最开始(我们截取)的地方,有个叫做why的神秘变量,它指示了在退出这个巨大的for循环时python执行引擎的状态。因为python执行引擎不一定每一次执行都正确无误,很有可能在执行到某条字节码的时候出现了错误,这也就是我们在python中经常看到的异常--exception。所以在python退出了执行引擎的时候,就需要知道执行引擎到底是因为什么原因结束了对字节码指令的执行。是正常结束、还是因为有错误而导致执行不下去了等等。why则义无反顾地承担起这一重任。关于why这一变量在虚拟机中的详细作用,我们在分析异常机制的时候再详细介绍。

    但是why的取值范围我们是可以直接在ceval.c中看到的,其实也就是python结束字节码执行时候的状态

    enum why_code {
            WHY_NOT =       0x0001, /* No error 没有错误 */
            WHY_EXCEPTION = 0x0002, /* Exception occurred 发生错误 */
            WHY_RETURN =    0x0008, /* 'return' statement return语句*/
            WHY_BREAK =     0x0010, /* 'break' statement break语句*/
            WHY_CONTINUE =  0x0020, /* 'continue' statement continue语句*/
            WHY_YIELD =     0x0040, /* 'yield' operator yield操作*/
            WHY_SILENCED =  0x0080  /* Exception silenced by 'with' 异常被with语句解除*/
    };
    
    

    尽管只是简单的分析,但是相信大家也能了解python执行引擎的大体框架,在python的执行流程进入了那个for循环,取出第一条字节码之后,第一张多米诺骨牌就已经被推倒,命运不可阻挡的降临了。一条接一条的字节码像潮水一样用来,浩浩荡荡,横无际涯。(不得不说,陈儒老师的文字太有魅力了,一定是一个充满幽默的人)

    9.4 python运行时环境初探

    到目前为止,我们除了看到python虚拟机的整体执行框架,还看到了python虚拟机在执行时需要不断使用的执行环境(也就是所谓的栈帧)。但是对于一个可执行文件来说,要想在操作系统中运行,只有栈帧(PyFrameObject)是不够的。对于可执行文件来说,还有两个至关重要的概念:进程和线程。

    我们先对python的运行模型(主要是线程)进行一个整体概念的了解,虽然这部分我们会在剖析多线程的时候再详细考察,但是由于python在初始化时默认会创建一个主线程,因此对线程模型还是要先有一定了解的

    我们知道对于一个可执行程序,如果执行,那么操作系统势必会创建一个进程,在进程中,又会创建一个主线程;而对于多线程来说,操作系统则会创建一个进程和多个线程,多个线程能够共享进程地址空间中的全局变量。CPU对任务之间的切换实际上就是对线程之间的切换,在切换任务时,CPU需要执行线程环境的保存工作,而在切换至新的线程之后,需要恢复该线程的执行环境。另外关于进程和线程,我们有以下结论:

    • 线程是操作系统调度的最小单元,有人说协程呢?协程操作系统是感知不到的。线程是真正用来操作cpu执行指令的。
    • 进程是操作系统资源分配的最小单元,进程是不负责执行的,它只是为线程提供资源,真正干活的是线程
    • 进程好比盖一间屋子,而线程则是拉一个人进去干活。因此如果问,创建线程和创建进程哪个块,肯定是创建线程块,创建进程是非常耗费资源的。
    • 如果问你线程和进程的执行速度哪个快,那么这个问题本身是有问题的,因为进程是不负责执行任务的。如果涉及到进程,只能比较创建时间。同理如果问两个进程哪个执行快,这也是没有意义的,如果是执行速度,那只能是这两个进程中的线程哪个执行快。

    对于python中的线程,底层就是对c语言中的线程进行了一个封装。一个python中的线程底层就是一个c语言中的线程,最终对应一个操作系统的线程。这里对线程的概念不做深究,只需要知道python在执行程序的时候,可能会有多个线程存在

    CPU切换任务时需要保存线程运行环境,对于python来说,在切换线程之前,同样需要保存线程的信息。在python中对于线程状态信息的抽象是通过PyThreadState对象来实现的,一个线程将拥有一个PyThreadState对象。所以从另一种意义上来说,这个PyThreadState对象也可以看做是对线程本身的抽象。但是注意:PyThreadState并非是模拟线程本身,因为python中线程使用的就是操作系统的原生线程,PyThreadState仅仅是线程状态的一个抽象

    另外,我们也说了进程是为线程提供资源的,这就意味着线程是无法独立存在的,必须寄托于进程之中,多个线程也是如此,都是寄托于同一个进程当中,共享进程中的资源。另外,如果多个线程同时import os,这不是意味着每个线程都要有一个单独的os呢?我相信即使不明白原理,也能够百分百断定肯定不会每个线程都会有一个单独的os,如果每个线程都有自己单独的module集合,那么python对内存的消耗显然是惊人的。因此除了全局变量,这些module也是全局共享的,一个线程导入之后,另一个线程再导入的时候就不会单独导入了,而是直接到已经导入的模块中去找。对于进程这个抽象概念,python是以PyInterpreterState对象来实现。

    在win32或者win64下,通常会有多个进程,而python实际上也可以有多个逻辑上的interpreter。通常情况下,python只有一个interpreter,这个interpreter中维护了多个PyThreadState对象,与PyThreadState对象对应的线程轮流使用一个字节码执行引擎。既然提到多线程,就不得不提到线程同步,在python中是通过GIL(Global Interpreter Local),全局解释器锁来实现同步的,这也是python被人诟病的万恶之源,至少个人觉得GIL也不全是缺点,当然这是废话,要全是缺点还设计它干嘛。关于线程同步的内容,我们还是留到剖析多线程机制的时候再详细考察。

    下面我们来看一下刚才提到的那两个对象的源码长什么样子,一个是描述线程的PyThreadState,一个是描述进程的PyInterpreterState

    //pystate.h
    typedef struct _ts {
        struct _ts *prev;
        struct _ts *next;
        PyInterpreterState *interp;
        struct _frame *frame; //模拟线程中的函数调用堆栈
        int recursion_depth;
        char overflowed; 
        char recursion_critical; 
        int stackcheck_counter;
        int tracing;
        int use_tracing;
        Py_tracefunc c_profilefunc;
        Py_tracefunc c_tracefunc;
        PyObject *c_profileobj;
        PyObject *c_traceobj;
        PyObject *curexc_type;
        PyObject *curexc_value;
        PyObject *curexc_traceback;
        _PyErr_StackItem exc_state;
        _PyErr_StackItem *exc_info;
        PyObject *dict;  /* Stores per-thread state */
        int gilstate_counter;
        PyObject *async_exc; /* Asynchronous exception to raise */
        unsigned long thread_id; /* Thread id where this tstate was created */
        int trash_delete_nesting;
        PyObject *trash_delete_later;
        void (*on_delete)(void *);
        void *on_delete_data;
        int coroutine_origin_tracking_depth;
        PyObject *coroutine_wrapper;
        int in_coroutine_wrapper;
        PyObject *async_gen_firstiter;
        PyObject *async_gen_finalizer;
        PyObject *context;
        uint64_t context_ver;
        uint64_t id;
    } PyThreadState;
    
    
    typedef struct _is {
        struct _is *next;
        struct _ts *tstate_head;//模拟进程环境中的线程集合
        int64_t id;
        int64_t id_refcount;
        PyThread_type_lock id_mutex;
        PyObject *modules;
        PyObject *modules_by_index;
        PyObject *sysdict;
        PyObject *builtins;
        PyObject *importlib;
        int check_interval;
        long num_threads;
        size_t pythread_stacksize;
        PyObject *codec_search_path;
        PyObject *codec_search_cache;
        PyObject *codec_error_registry;
        int codecs_initialized;
        int fscodec_initialized;
        _PyCoreConfig core_config;
        _PyMainInterpreterConfig config;
    #ifdef HAVE_DLOPEN
        int dlopenflags;
    #endif
        PyObject *builtins_copy;
        PyObject *import_func;
        _PyFrameEvalFunction eval_frame;
        Py_ssize_t co_extra_user_count;
        freefunc co_extra_freefuncs[MAX_CO_EXTRA_USERS];
    #ifdef HAVE_FORK
        PyObject *before_forkers;
        PyObject *after_forkers_parent;
        PyObject *after_forkers_child;
    #endif
        void (*pyexitfunc)(PyObject *);
        PyObject *pyexitmodule;
        uint64_t tstate_next_unique_id;
    } PyInterpreterState;
    
    

    PyThreadState中我们看到了熟悉的PyFrameObject对象,也就是说在每个PyThreadState对象中,会维护一个栈帧的列表,以与线程中的函数调用机制相对应。

    关于PyThreadStatePyInterpreterState的创建在后面介绍,我们可以先看看PyThreadStatePyFrameObject对象之间的交互和联系。当python虚拟机开始执行时,会将当然线程状态对象中的frame设置为当前的执行环境(frame):

    PyObject* _Py_HOT_FUNCTION
    _PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag)
    {
        //通过PyThreadState_GET获得当前活动线程对应的线程状态对象
        PyThreadState *tstate = PyThreadState_GET();
        
        //设置线程状态对象中的frame
        tstate->frame = f;
        co = f->f_code;
        names = co->co_names;
        consts = co->co_consts;
        
        //虚拟机主循环
        for (;;){
            //指令分派
            switch (opcode)
        }
    
    

    而在建立新的PyFrameObject对象时,则从当前线程状态中取出旧的frame,建立PyFrameObject链表

    PyFrameObject*
    PyFrame_New(PyThreadState *tstate, PyCodeObject *code,
                PyObject *globals, PyObject *locals)
    {
        PyFrameObject *f = _PyFrame_New_NoTrack(tstate, code, globals, locals);
        if (f)
            _PyObject_GC_TRACK(f);
        return f;
    }
    
    PyFrameObject* _Py_HOT_FUNCTION
    _PyFrame_New_NoTrack(PyThreadState *tstate, PyCodeObject *code,
                         PyObject *globals, PyObject *locals)
    {	
        //获取当前线程的当前执行环境
        PyFrameObject *back = tstate->frame;
        PyFrameObject *f;
        PyObject *builtins;
        Py_ssize_t i;
    
    	
        /*
        ...
        */			
        		   //创建新的执行环境	
                    PyFrameObject *new_f = PyObject_GC_Resize(PyFrameObject, f, extras);
                    if (new_f == NULL) {
                        PyObject_GC_Del(f);
                        Py_DECREF(builtins);
                        return NULL;
                    }
                    f = new_f;
                }
                _Py_NewReference((PyObject *)f);
            }
    
            f->f_code = code;
            extras = code->co_nlocals + ncells + nfrees;
            f->f_valuestack = f->f_localsplus + extras;
            for (i=0; i<extras; i++)
                f->f_localsplus[i] = NULL;
            f->f_locals = NULL;
            f->f_trace = NULL;
        }
        f->f_stacktop = f->f_valuestack;
        f->f_builtins = builtins;
        Py_XINCREF(back);
    	//链接当前执行环境
        f->f_back = back;
        Py_INCREF(code);
        Py_INCREF(globals);
        f->f_globals = globals;
        /* Most functions have CO_NEWLOCALS and CO_OPTIMIZED set. */
        if ((code->co_flags & (CO_NEWLOCALS | CO_OPTIMIZED)) ==
            (CO_NEWLOCALS | CO_OPTIMIZED))
            ; /* f_locals = NULL; will be set by PyFrame_FastToLocals() */
        else if (code->co_flags & CO_NEWLOCALS) {
            locals = PyDict_New();
            if (locals == NULL) {
                Py_DECREF(f);
                return NULL;
            }
            f->f_locals = locals;
        }
        else {
            if (locals == NULL)
                locals = globals;
            Py_INCREF(locals);
            f->f_locals = locals;
        }
    
        f->f_lasti = -1;
        f->f_lineno = code->co_firstlineno;
        f->f_iblock = 0;
        f->f_executing = 0;
        f->f_gen = NULL;
        f->f_trace_opcodes = 0;
        f->f_trace_lines = 1;
    
        return f;
    }
    
    

    一张图来总结一下

  • 相关阅读:
    流程控制引擎组件化
    (七):C++分布式实时应用框架 2.0
    (六):大型项目容器化改造
    (五):C++分布式实时应用框架——微服务架构的演进
    (四):C++分布式实时应用框架——状态中心模块
    (三):C++分布式实时应用框架——系统管理模块
    (二): 基于ZeroMQ的实时通讯平台
    (一):C++分布式实时应用框架----整体介绍
    分布式压测系列之Jmeter4.0第一季
    选择 NoSQL 需要考虑的 10 个问题
  • 原文地址:https://www.cnblogs.com/traditional/p/11825465.html
Copyright © 2011-2022 走看看