100篇文章啦 ! ! 给自己鼓个掌, 然后今天就让我们来看一下Python的虚拟机框架吧. 我们从整个虚拟机的执行环境来看起来, 接着从命名空间和作用域的角度来切入看下Python虚拟机的运行时环境.
废话不说了, 开始吧!
Python的虚拟机基本认识
说道这个虚拟机啊, 其实就和我们操作系统运行可执行程序差不多的设计. 以我们现在的x86运行可执行程序为例, 我们内存中会被组织成为一个个运行时栈, 随着一层层的调用关系, 一个个新的东西被压入栈.
这个所谓的东西. 其实也是有个名字的, 叫做帧. 我们知道esp代表的是栈指针, 那帧指针就是ebp. 举个例子, 当我们进行了一次函数调用的时候, 系统保存上一个帧的栈指针和帧指针, 当内层函数执行结束之后, 就会按照记录的位置返回回去. 从而使得程序的运行空间回到了调用者的那个帧.
Python的虚拟机执行, 和这个过程几乎无异. 我们在之前了解到了Python的字节码对象, 这个CodeObject包含了许多虚拟机执行字节码所需要的信息, 但是注意: CodeObject所包含的信息 都是静态的. 然而我们的程序执行是一个动态的过程, 一个静态的对象是无法携带动态信息的.
什么是动态的信息? 举个例子, 我们最常见的一个动态环境就是 — 作用域(命名空间)了:
1 | def foo(): |
上述代码作为一个可执行模块运行的时候, 会打印两次符号i
的值, 但是这两个i
的值是不一样的, 这是因为作用域不一样, 命名空间发生了切换, 所以同样的符号代表的东西不一样. 用之前我们对Python字节码和CodeObject的了解, 我们可以确定两个print(i)
产生的字节码是一样, 同样的字节码, 执行的结果却不一样, 这就是因为引入了动态环境.
用上面的说法来说就是, 新的执行环境被创建就是说新的帧被创建并且压入到了执行栈中.
这么一个帧的抽象, 就是Python的帧对象 — PyFrameObject
认识帧对象
现在就让我们来看一下这个帧对象的定义是什么样子的吧.
1 | typedef struct _frame { |
初次这么一看还真的是有一点复杂, 除了那个固定的头部, 下一个就是串联这些帧的, 像链表一样把这些帧联系起来.
接着就是这个帧中的代码对象, 之后的就是builtin
, globals
, locals
的命名空间. 所以现在我们稍微有点感觉了, 命名空间看起来其实就是一个字典. 关于命名空间我们再后面的小节继续看.
关于这个对象的头部, 是Python的可变长对象的固定头部, 这就说明这个帧对象的长度是发生变化的, 来看上面代码定义的最后一行. 通过注释我们也可以搞清楚 这个就是这个帧对象所维护的一块动态内存, 包括变量, 对象集合还有运行时栈. 而且不仅如此, 这个帧对象的里面还包括一个代码对象呢. 不同的代码对象在执行的时候所需要的栈空间是不一样的, 一个代码对象所需要的空间到底有多大, 只有当我们编译这个CodeObject的时候才能知道, 这也就是为什么帧对象也是一个可变对象.
现在就让我们近距离的接触一下这个对象吧, 尽管这是一个非常底层和私密的对象, 但是Python还是实现了一个C级别的方法, 可以让我们访问到这个对象, 来试试下面的代码:
1 | import sys |
;-) 很有趣吧 !
那么现在我们来关注一个小细节, 这个frame所维护的动态内存到底是由哪些东西组成的呢? 我们进入到这个帧新建的函数中: ( 删除了大量代码 )
1 | Py_ssize_t extras, ncells, nfrees; |
说明一下, code就是我们之前研究的代码对象 - CodeObject. 你会发现上面的代码中的extra
出现了重复赋值, 其实啊, 第一次的extra
就是frameobject所维护的动态内存大小, 至于第二次的那个, 是为了计算初始化的时候运行时栈的栈顶, 从而计算出后面的栈底和栈顶.
Python的核心概念 — 命名空间
我们在上面的访问帧对象的例子中展示了三个命名空间 - ( builtin, local, global ). 其实提到命名空间, 你一定想到了很多与之相关联的东西, 例如: 名字, 符号, 作用域这些. 接下来我们就来仔细的研究下这些和与之相关的话题吧.
说到命名空间 我们就从Python程序最基础的结构 - 模块开始说起. 我们知道, 对于一个不小的Python项目, 我们不会把代码都放在一个模块里面, 而是分多个.py
文件进行抽象, 模块化, 从而达到代码的复用, 其实除了这些, 我们还做了一件事情, 那就是划分命名空间. 在我们的Coding过程中, 每次声明一个变量, 声明一个函数或者创建一个类的时候, 其实都会提供一个名字, 这个名字倒不是很重要, 他只是一个单纯的名字, 真正重要的是名字背后的对象. 而找到这个对象的唯一途径, 就是通过这个名字.
我们知道, Python程序的执行就是模块的加载, 而加载方式有两种, 一种就是通过我们执行python main.py
这样的方式来加载主Module, 或者通过在模块中使用import
关键字进行的动态加载, 而在执行动态加载的时候就会将模块中代码全部执行一遍.
对了 在这里提一下, 我们的代码是逐行执行的, 也就是说:
1 | def foo(): |
这样子的代码, 会先执行def foo():
再执行pass
, 其实你可能会觉得奇怪, 为啥def foo()
这样的语句也是可以执行的, 其实如果我们向底层的方向看去就不会认为这是不可执行的语句啦.
事实上, 在Python中, def foo():pass
是一个赋值语句. 同样也是赋值语句的例子就是形如 a = 10
. 他们在执行的时候都会和下面的过程想吻合:
- 创建一个对象obj
- 将obj “赋给” 一个名字(或者是符号) name
除了这种简单的, 还有类似import XXX
, class Bar()
这样子的, 也都是赋值语句. 既然我们把这个obj和name建立了映射, 就会存在一种约束关系. 而这种约束就存在于我们的命名空间中.
而我们之前在说帧对象的时候就说明了, 命名空间在Python虚拟机中是使用的PyDictObject来表示的, 向上面的例子, 存储起来就是: (foo, function object), (a, 10)
.
那么既然存在这种属性赋值的语句, 自然就存在需要使用这些赋予的值的 属性引用 语句. 这些语句就类似: import A, print(A.a)
在这里, A.a
就是在访问A模块(module)所定义的命名空间中的a
属性.
注意哦, 这里我们讨论的是模块和模块之间的命名空间. 但是其实更复杂的是在模块中命名空间的相关规则. 其实你大概可以猜出来, 这种规则应该会嵌套的 !
Python的核心概念 — 作用域
按照我们刚刚在上一个节说的规则, 创建约束, 放到命名空间中, 这样看起来是可行的. 来看这么一段代码:
1 | a = 10 |
如果说只要按照基本的规则, 这里输出的a应该值是一样的, 但是显然不是这样. 这就说明肯定是有两个约束被建立, 尽管他们的键是一样的! 但是这是不可能的, 因为一个字典的键不可能重复. 等等, 如果说有多个字典呢?
这里我们就可以引入Python的作用域的概念啦! 其实大家都了解的, 关于作用域, 上述代码中的函数定义就建立了一个作用域, 我们说的约束, 就是在这个作用域中才可以起到作用, 当我们进行作用域的切换的时候, 约束的作用也在变化. 那么问题就来了, 我们怎么知道一个约束的效果是怎么起作用的呢? 我们的Python源程序说到底是纯文本, 也就是说在我们写好这个程序的时候, 作用域的位置就已经可以确定了(静态的).
我们在上面的属性引用段落中, 使用了A.a
这样子的语句, 这里就是明确指出使用A模块作用域中的a
, 我们访问或者说引用的是A中的名字为a的对象. 但是说如果在B中也有一个a, 想要引用访问它, 我只需要写一个a就行了, 这就是直接引用.
那假如说在上面代码中, 函数foo的定义中, 没有a的定义, 也就是删去第3行的代码. 那么当我打印a的时候会发生什么? 在foo所生成的作用域中, 没有名字为a的对象, 这个时候 我们就要到上一个级别的, 也就是嵌套的作用域. 由此, Python使用的是最内嵌套作用域规则, 即: 一个赋值语句所带来的作用域对它内部的作用域依然可见, 除非被引进同样名字的另一个赋值语句所遮蔽.
LGB规则和LEGB规则
还记得我们一开始在帧对象的时候看到的几个命名空间吗: locals, globals, builtins
. 所谓LGB, 其实就是说Python会沿着这个路线进行符号的检索, 所谓local, 就是类似我们一个函数所定义出来的区域, 而global就是一个模块的顶层命名空间, 最后, Python自己定义了一个最顶级的命名空间: builtin. 这里面就存在这我们的range, dir, open
等等函数符号了.
有的时候, 我们的local和global可能会是一样的, 例如我们上面的代码, 第二个print(a)
. 所对应的local和global就是一样的, 说到底, 其实就是帧对象中的f_locals
和f_globals
对应的是同一个PyDictObject了.
那么啥是LEGB, 这个E其实就是enclosing
的意思, 其实就是闭包了, 很久之前我曾经写过关于Javascript的闭包问题. 其实这个地方是差不多的, 一个简单的例子就是:
1 | a = 10 |
显然, 上述代码执行之后打印的结果是2. 其实按照我们之前说的道理还是可以说通的, 因为作用域是静态的 bar的定义在foo里面, 所以显然foo的名字对bar也是可见的. 其实Python在处理的时候, 是把a=2
这一句赋值语句所创建的约束, 和下面定义的bar
函数对象绑定在了一起, 这个绑定的东西其实就是闭包.
小测验
来看看到底有没有搞清楚这个神奇的作用域规则吧.
1 | a = 10 |
上面的代码执行会发生什么?
答案是: 会抛出运行时异常.
原因就在bar所企图打印的第一个a, 还没有被赋值. 因为即使我们在global中存在a, 但是我们在bar中依然定义了a这个名字, 尽管, 他还暂时没有被赋值, 但是我们已经在命名空间中把它写入了. 如果你不信, 我们可以来看一下这一段的字节码.
1 | import dis |
可以看到, 在foo
中和bar
中的字节码根本不一样, 对于后者, 他所使用的是LOAD_FAST
指令.
其实这里也就是我们在上面所说的遮蔽了. 但是有的时候, 我确实是需要先打印globals中的a, 接着想要再次赋值, 这怎么办呢? 没关系, 通过使用python提供的global
关键字, 就可以达到效果了.
1 | a = 10 |
以上就是对Python的名字引用的基础讨论了, 除了名字引用, 我们还有属性引用, 但是属性引用, 就显得很简单了, 他只会在当前的名字的名字的作用域中进行搜索, 有就是有了, 没有就没有.
Python虚拟机运行时环境初探
我们之前讨论的都是执行环境, 而现在所涉及到的是叫做运行时环境. 在Python中, 通过模拟x86的栈帧来执行py程序. 那么整个程序是怎么开始执行的呢, 我们可以看一下在ceval.c
中所定义的函数: PyEval_EvalFrameEx
. 这是一个无比巨大的函数, 算上注释大概有2k+行, 其实本质上就是对Python虚拟机的实现, 实在是看不下去, 所以只能按照书中的, 简单的先看一下这个函数的整体架构和一些比较明显的动作.
首先, 肯定是初始化操作:
1 | co = f->f_code; |
这里涉及到了我们之前所说的code对象和frame对象, 另外还做了一个超级重要的事情, 那就是初始化栈顶指针.我们可以看到这里面有三个变量: first_instr
, next_instr
, f_lasti
. first所指向的, 就是这个字节码序列开始的地方, 而last永远指向上一条执行的指令位置, 至于next, 表示的就是下一条要指向的指令位置. 这些指针是怎么进行切换的呢, 我们来看 .
1 | for (;;) { |
一个for循环, 内部是一个巨大的switch/case分支. 这里面出现了一些宏, 我们来看一下定义:
1 |
其实这些就是在进行下一条指令指针的移动, Python在执行过程中会遇到不同的字节码指令, 接着就会会根据这些指令进行switch切换从而去实现不同的功能, 就是这样, 来进行程序的执行.
现在就清晰多了吧 ! ! 最后我们再来提及一下这里面的一个神奇的变量, 叫做: why
.
这个是用来记录当指令执行遇到错误的时候, 也就是发生异常的时候 停止执行的原因是什么. 有这些:
1 | /* Status code for main loop (reason for stack unwind) */ |
虽然说我们现在稍微清楚了一点关于栈帧的事情, 但是实际上从操作系统的层面上, 我们对执行的程序抽象其实是进程和线程. 那么对于这个, Python的运行模型是个什么样子呢?
同样, Python运行时也会至少存在一条主线程, 而Python也实现了对多线程的支持, 对于Python来说 我们上面说的那个虚拟机框架对于Python而言, 其实就是一个软CPU, 而所谓多线程的实现其实就是不断的切换使用这个软CPU.