Python的虚拟机框架(1)

100篇文章啦 ! ! 给自己鼓个掌, 然后今天就让我们来看一下Python的虚拟机框架吧. 我们从整个虚拟机的执行环境来看起来, 接着从命名空间和作用域的角度来切入看下Python虚拟机的运行时环境.

废话不说了, 开始吧!

Python的虚拟机基本认识

说道这个虚拟机啊, 其实就和我们操作系统运行可执行程序差不多的设计. 以我们现在的x86运行可执行程序为例, 我们内存中会被组织成为一个个运行时栈, 随着一层层的调用关系, 一个个新的东西被压入栈.

这个所谓的东西. 其实也是有个名字的, 叫做. 我们知道esp代表的是栈指针, 那帧指针就是ebp. 举个例子, 当我们进行了一次函数调用的时候, 系统保存上一个帧的栈指针和帧指针, 当内层函数执行结束之后, 就会按照记录的位置返回回去. 从而使得程序的运行空间回到了调用者的那个帧.

Python的虚拟机执行, 和这个过程几乎无异. 我们在之前了解到了Python的字节码对象, 这个CodeObject包含了许多虚拟机执行字节码所需要的信息, 但是注意: CodeObject所包含的信息 都是静态的. 然而我们的程序执行是一个动态的过程, 一个静态的对象是无法携带动态信息的.

什么是动态的信息? 举个例子, 我们最常见的一个动态环境就是 — 作用域(命名空间)了:

1
2
3
4
5
6
7
8
def foo():
i = 5
print(i)


i = 1
print(i)
foo()

上述代码作为一个可执行模块运行的时候, 会打印两次符号i的值, 但是这两个i的值是不一样的, 这是因为作用域不一样, 命名空间发生了切换, 所以同样的符号代表的东西不一样. 用之前我们对Python字节码和CodeObject的了解, 我们可以确定两个print(i)产生的字节码是一样, 同样的字节码, 执行的结果却不一样, 这就是因为引入了动态环境.

用上面的说法来说就是, 新的执行环境被创建就是说新的帧被创建并且压入到了执行栈中.

这么一个帧的抽象, 就是Python的帧对象 — PyFrameObject

认识帧对象

现在就让我们来看一下这个帧对象的定义是什么样子的吧.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
typedef struct _frame {
PyObject_VAR_HEAD
struct _frame *f_back; /* previous frame, or NULL */
PyCodeObject *f_code; /* code segment */
PyObject *f_builtins; /* builtin symbol table (PyDictObject) */
PyObject *f_globals; /* global symbol table (PyDictObject) */
PyObject *f_locals; /* local symbol table (any mapping) */
PyObject **f_valuestack; /* points after the last local */
/* Next free slot in f_valuestack. Frame creation sets to f_valuestack.
Frame evaluation usually NULLs it, but a frame that yields sets it
to the current stack top. */
PyObject **f_stacktop;
PyObject *f_trace; /* Trace function */
...
PyThreadState *f_tstate;
int f_lasti; /* Last instruction if called */
/* As of 2.3 f_lineno is only valid when tracing is active (i.e. when
f_trace is set) -- at other times use PyCode_Addr2Line instead. */
int f_lineno; /* Current line number */
int f_iblock; /* index in f_blockstack */
PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* for try and loop blocks */
PyObject *f_localsplus[1]; /* locals+stack, dynamically sized */
} PyFrameObject;

初次这么一看还真的是有一点复杂, 除了那个固定的头部, 下一个就是串联这些帧的, 像链表一样把这些帧联系起来.

接着就是这个帧中的代码对象, 之后的就是builtin, globals, locals的命名空间. 所以现在我们稍微有点感觉了, 命名空间看起来其实就是一个字典. 关于命名空间我们再后面的小节继续看.

关于这个对象的头部, 是Python的可变长对象的固定头部, 这就说明这个帧对象的长度是发生变化的, 来看上面代码定义的最后一行. 通过注释我们也可以搞清楚 这个就是这个帧对象所维护的一块动态内存, 包括变量, 对象集合还有运行时栈. 而且不仅如此, 这个帧对象的里面还包括一个代码对象呢. 不同的代码对象在执行的时候所需要的栈空间是不一样的, 一个代码对象所需要的空间到底有多大, 只有当我们编译这个CodeObject的时候才能知道, 这也就是为什么帧对象也是一个可变对象.

现在就让我们近距离的接触一下这个对象吧, 尽管这是一个非常底层和私密的对象, 但是Python还是实现了一个C级别的方法, 可以让我们访问到这个对象, 来试试下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
>>> import sys
>>> global_value = 1
>>> def g():
... frame = sys._getframe()
... print("Current function is %s" % frame.f_code.co_name)
... caller = frame.f_back
... print("Caller function is %s" % caller.f_code.co_name)
... print("Caller's local namespace: %s" % caller.f_locals)
... print("Caller's global namespace: %s" % caller.f_globals.keys())
...
>>> def f():
... local_value = 2
... g()
...
>>> def main():
... f()
...
>>> main()
Current function is g
Caller function is f
Caller's local namespace: {'local_value': 2}
Caller's global namespace: dict_keys(['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__annotations__', '__builtins__', 'sys', 'global_value', 'g', 'f', 'main'])

;-) 很有趣吧 !

那么现在我们来关注一个小细节, 这个frame所维护的动态内存到底是由哪些东西组成的呢? 我们进入到这个帧新建的函数中: ( 删除了大量代码 )

1
2
3
4
5
6
7
8
Py_ssize_t extras, ncells, nfrees;
ncells = PyTuple_GET_SIZE(code->co_cellvars);
nfrees = PyTuple_GET_SIZE(code->co_freevars);
extras = code->co_stacksize + code->co_nlocals + ncells + nfrees;
...
extras = code->co_nlocals + ncells + nfrees;
f->f_valuestack = f->f_localsplus + extras; // 这个就是运行时栈的栈底
f->f_stacktop = f->f_valuestack; // 运行时栈的栈顶

说明一下, code就是我们之前研究的代码对象 - CodeObject. 你会发现上面的代码中的extra出现了重复赋值, 其实啊, 第一次的extra就是frameobject所维护的动态内存大小, 至于第二次的那个, 是为了计算初始化的时候运行时栈的栈顶, 从而计算出后面的栈底和栈顶.

Python的核心概念 — 命名空间

我们在上面的访问帧对象的例子中展示了三个命名空间 - ( builtin, local, global ). 其实提到命名空间, 你一定想到了很多与之相关联的东西, 例如: 名字, 符号, 作用域这些. 接下来我们就来仔细的研究下这些和与之相关的话题吧.

说到命名空间 我们就从Python程序最基础的结构 - 模块开始说起. 我们知道, 对于一个不小的Python项目, 我们不会把代码都放在一个模块里面, 而是分多个.py文件进行抽象, 模块化, 从而达到代码的复用, 其实除了这些, 我们还做了一件事情, 那就是划分命名空间. 在我们的Coding过程中, 每次声明一个变量, 声明一个函数或者创建一个类的时候, 其实都会提供一个名字, 这个名字倒不是很重要, 他只是一个单纯的名字, 真正重要的是名字背后的对象. 而找到这个对象的唯一途径, 就是通过这个名字.

我们知道, Python程序的执行就是模块的加载, 而加载方式有两种, 一种就是通过我们执行python main.py这样的方式来加载主Module, 或者通过在模块中使用import关键字进行的动态加载, 而在执行动态加载的时候就会将模块中代码全部执行一遍.

对了 在这里提一下, 我们的代码是逐行执行的, 也就是说:

1
2
def foo():
pass

这样子的代码, 会先执行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
2
3
4
5
a = 10
def foo():
a = 100
print(a) # 输出100
print(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_localsf_globals对应的是同一个PyDictObject了.

那么啥是LEGB, 这个E其实就是enclosing的意思, 其实就是闭包了, 很久之前我曾经写过关于Javascript的闭包问题. 其实这个地方是差不多的, 一个简单的例子就是:

1
2
3
4
5
6
a = 10
def foo():
a = 2
def bar():
print(a)
foo()

显然, 上述代码执行之后打印的结果是2. 其实按照我们之前说的道理还是可以说通的, 因为作用域是静态的 bar的定义在foo里面, 所以显然foo的名字对bar也是可见的. 其实Python在处理的时候, 是把a=2这一句赋值语句所创建的约束, 和下面定义的bar函数对象绑定在了一起, 这个绑定的东西其实就是闭包.

小测验

来看看到底有没有搞清楚这个神奇的作用域规则吧.

1
2
3
4
5
6
7
8
9
10
11
12
a = 10

def foo():
print(a)

def bar():
print(a)
a = 2
print(a)

foo()
bar()

上面的代码执行会发生什么?

答案是: 会抛出运行时异常.

原因就在bar所企图打印的第一个a, 还没有被赋值. 因为即使我们在global中存在a, 但是我们在bar中依然定义了a这个名字, 尽管, 他还暂时没有被赋值, 但是我们已经在命名空间中把它写入了. 如果你不信, 我们可以来看一下这一段的字节码.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
>>> import dis
>>> src = open("test.py").read()
>>> code = compile(src, "test.py", "exec")
>>> code.co_consts
(10, <code object foo at 0x102aaf8a0, file "test.py", line 11>, 'foo', <code object bar at 0x102acf420, file "test.py", line 14>, 'bar', None)
>>> dis.dis(code.co_consts[1])
12 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
>>> dis.dis(code.co_consts[3])
15 0 LOAD_GLOBAL 0 (print)
2 LOAD_FAST 0 (a)
4 CALL_FUNCTION 1
6 POP_TOP

16 8 LOAD_CONST 1 (2)
10 STORE_FAST 0 (a)

17 12 LOAD_GLOBAL 0 (print)
14 LOAD_FAST 0 (a)
16 CALL_FUNCTION 1
18 POP_TOP
20 LOAD_CONST 0 (None)
22 RETURN_VALUE

可以看到, 在foo中和bar中的字节码根本不一样, 对于后者, 他所使用的是LOAD_FAST指令.

其实这里也就是我们在上面所说的遮蔽了. 但是有的时候, 我确实是需要先打印globals中的a, 接着想要再次赋值, 这怎么办呢? 没关系, 通过使用python提供的global关键字, 就可以达到效果了.

1
2
3
4
5
6
7
8
9
10
11
12
13
a = 10

def foo():
print(a)

def bar():
global a
print(a)
a = 2
print(a)

foo()
bar()

以上就是对Python的名字引用的基础讨论了, 除了名字引用, 我们还有属性引用, 但是属性引用, 就显得很简单了, 他只会在当前的名字的名字的作用域中进行搜索, 有就是有了, 没有就没有.

Python虚拟机运行时环境初探

我们之前讨论的都是执行环境, 而现在所涉及到的是叫做运行时环境. 在Python中, 通过模拟x86的栈帧来执行py程序. 那么整个程序是怎么开始执行的呢, 我们可以看一下在ceval.c中所定义的函数: PyEval_EvalFrameEx. 这是一个无比巨大的函数, 算上注释大概有2k+行, 其实本质上就是对Python虚拟机的实现, 实在是看不下去, 所以只能按照书中的, 简单的先看一下这个函数的整体架构和一些比较明显的动作.

首先, 肯定是初始化操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
co = f->f_code;
names = co->co_names;
consts = co->co_consts;
fastlocals = f->f_localsplus;
freevars = f->f_localsplus + co->co_nlocals;
first_instr = (unsigned char*) PyString_AS_STRING(co->co_code);
/* An explanation is in order for the next line.

f->f_lasti now refers to the index of the last instruction
executed. You might think this was obvious from the name, but
this wasn't always true before 2.3! PyFrame_New now sets
f->f_lasti to -1 (i.e. the index *before* the first instruction)
and YIELD_VALUE doesn't fiddle with f_lasti any more. So this
does work. Promise. */
next_instr = first_instr + f->f_lasti + 1;
stack_pointer = f->f_stacktop;
assert(stack_pointer != NULL);
f->f_stacktop = NULL; /* remains NULL unless yield suspends frame */

这里涉及到了我们之前所说的code对象和frame对象, 另外还做了一个超级重要的事情, 那就是初始化栈顶指针.我们可以看到这里面有三个变量: first_instr, next_instr, f_lasti. first所指向的, 就是这个字节码序列开始的地方, 而last永远指向上一条执行的指令位置, 至于next, 表示的就是下一条要指向的指令位置. 这些指针是怎么进行切换的呢, 我们来看 .

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
for (;;) {
...
f->f_lasti = INSTR_OFFSET();

/* line-by-line tracing support */

if (tstate->c_tracefunc != NULL && !tstate->tracing) {
/* see maybe_call_line_trace
for expository comments */
f->f_stacktop = stack_pointer;

err = maybe_call_line_trace(tstate->c_tracefunc,
tstate->c_traceobj,
f, &instr_lb, &instr_ub,
&instr_prev);
/* Reload possibly changed frame fields */
JUMPTO(f->f_lasti);
if (f->f_stacktop != NULL) {
stack_pointer = f->f_stacktop;
f->f_stacktop = NULL;
}
if (err) {
/* trace function raised an exception */
goto on_error;
}
}

/* Extract opcode and argument */

opcode = NEXTOP();
oparg = 0; /* allows oparg to be stored in a register because
it doesn't have to be remembered across a full loop */
if (HAS_ARG(opcode))
oparg = NEXTARG();
...

一个for循环, 内部是一个巨大的switch/case分支. 这里面出现了一些宏, 我们来看一下定义:

1
2
3
4
5
6
#define INSTR_OFFSET()	((int)(next_instr - first_instr))
#define NEXTOP() (*next_instr++)
#define NEXTARG() (next_instr += 2, (next_instr[-1]<<8) + next_instr[-2])
#define PEEKARG() ((next_instr[2]<<8) + next_instr[1])
#define JUMPTO(x) (next_instr = first_instr + (x))
#define JUMPBY(x) (next_instr += (x))

其实这些就是在进行下一条指令指针的移动, Python在执行过程中会遇到不同的字节码指令, 接着就会会根据这些指令进行switch切换从而去实现不同的功能, 就是这样, 来进行程序的执行.

现在就清晰多了吧 ! ! 最后我们再来提及一下这里面的一个神奇的变量, 叫做: why.

这个是用来记录当指令执行遇到错误的时候, 也就是发生异常的时候 停止执行的原因是什么. 有这些:

1
2
3
4
5
6
7
8
9
10
/* Status code for main loop (reason for stack unwind) */
enum why_code {
WHY_NOT = 0x0001, /* No error */
WHY_EXCEPTION = 0x0002, /* Exception occurred */
WHY_RERAISE = 0x0004, /* Exception re-raised by 'finally' */
WHY_RETURN = 0x0008, /* 'return' statement */
WHY_BREAK = 0x0010, /* 'break' statement */
WHY_CONTINUE = 0x0020, /* 'continue' statement */
WHY_YIELD = 0x0040 /* 'yield' operator */
};

虽然说我们现在稍微清楚了一点关于栈帧的事情, 但是实际上从操作系统的层面上, 我们对执行的程序抽象其实是进程和线程. 那么对于这个, Python的运行模型是个什么样子呢?

同样, Python运行时也会至少存在一条主线程, 而Python也实现了对多线程的支持, 对于Python来说 我们上面说的那个虚拟机框架对于Python而言, 其实就是一个软CPU, 而所谓多线程的实现其实就是不断的切换使用这个软CPU.