虚拟机
在前几篇文章里,我们经常会提到 bytecode,也就是字节码,也会说到 Python 的虚拟机会做什么。但是我们从来没有专门讲过 Python 的虚拟机究竟是如何工作的,它背后的工作机制是什么。这篇文章就简单地介绍一下 Python 的虚拟机是怎么工作的。目的倒不是让你完全理解 Python 的虚拟机,只是为了让你以后再看我分析 CPython 代码的时候,知道虚拟机是怎么连起来的,对 Python 程序是怎么运行的有一个大致的了解。
frame 与 code object
我们之前在 code object 那篇文章里提到过,所有的 Python 代码都会被编译成一个 code object。Python 解释器在运行 Python 代码之前会建立一个 frame。大家可以看,inspect 的官方文档给出了跟 frame 有关的一些信息。frame 不是这篇文章的重点,你只需要知道 frame 提供了一个程序运行的环境,里面包含局部变量、全局变量、这段代码对应的 bytecode,还有很重要的一个是上一个运行的 instruction,也就是上一个运行的 bytecode。通过改变它,代码就可以完成一些分支操作。除了最开始运行 Python 代码建立的 frame 之外,之后每一次调用函数也会建立一个 frame。你可以理解成,所有的代码都是在一个 frame 里面执行的,而每一个 frame,除了最外面那个之外,都是一次函数调用。
字节码与栈
每次建立了 frame 之后,Python 解释器就要开始一条一条地执行字节码。比如下面这个简单的加法程序,用 dis 模块把字节码打印成人类能看懂的形式。当然,实际保存和运行的字节码是二进制数据。
import dis
s = """
def add(a, b):
return a + b
print(add(1, 2))
"""
dis.dis(s)
我们看这些字节码的类型,有 LOAD_CONST、LOAD_NAME、CALL_FUNCTION 等。Python 的字节码是 stack-based,也就是以栈为核心的结构。简单地说,每条字节码要么在进行计算,要么在对栈进行操作。
我们来看 add 函数编译成的字节码,它只有四条,很容易懂。第一条 LOAD_FAST 0 是把一个 local 变量压到栈里。如果你看过 code object 那篇文章,就知道这里的位置 0 为什么对应 a。接下来又运行一个 LOAD_FAST,位置 1 对应 b 这个变量里保存的值,再把这个值压栈。栈里现在就是 a 和 b。BINARY_ADD 从栈顶拿出两个数据,把它们加起来,再压回栈里。现在栈里就只有一个数据,就是 a + b。然后 RETURN_VALUE 把栈顶的数据返回,也就是返回 a + b。
字节码说明
具体每条字节码的功能是什么?大家可以看官方文档里 dis 模块的字节码说明。比如我们可以找到 LOAD_FAST,再看 BINARY_ADD 和 RETURN_VALUE:
把 co_varnames 里第 var_num 个数据压到栈里。这个 code object 知识是不是用上了?
实现 TOS = TOS1 + TOS。TOS 的意思是 top of stack,也就是栈顶:栈顶下面的数据加上栈顶的数据,再把结果放回栈顶。
把栈顶的数据返回给这个函数的调用者。
所以,当你不确定 Python 代码在底层是如何实现的时候,把它转成字节码,再去读字节码,是一个很有效的方式。这个方式我们之前也实践过很多次了。
CPython 源码
当然,有时候你会觉得官方文档的字节码说明还是不够详细,还想深入研究它在 C 语言层面是怎么实现的,那就只能去看 CPython 的源码了。
在 CPython 的源码里面,frame evaluation 的核心实现在 ceval.c 的 _PyEval_EvalFrameDefault。当然,运行这个函数的前提是正常地使用 CPython。实际上用户可以通过 frame evaluation 机制重载它。比如有些人可能知道 Numba 这样靠 JIT 对 Python 进行优化的库;JIT 也可以通过重载这个函数来接入 CPython。但是正常情况下运行 Python,都是运行这个函数。这个函数的函数体非常长,很可能是 CPython 里最长的函数。
执行循环
不停地运行 bytecode 直到结束,显然是一个循环该做的事。在 main_loop 这个无限循环里,跳过一些 tracing 代码后,比较重要的部分是一个很大的 switch (opcode),根据 opcode 做不同的操作。这就是运行 bytecode 的核心。
LOAD_FAST
我们先看 LOAD_FAST 对应的 C 代码:
case TARGET(LOAD_FAST): {
PyObject *value = GETLOCAL(oparg);
if (value == NULL) {
format_exc_check_arg(tstate, PyExc_UnboundLocalError,
UNBOUNDLOCAL_ERROR_MSG,
PyTuple_GetItem(co->co_varnames, oparg));
goto error;
}
Py_INCREF(value);
PUSH(value);
FAST_DISPATCH();
}
LOAD_FAST 让一个临时值等于 GETLOCAL(oparg)。oparg 就是 0、1、2 这些表示位置的变量。拿到值之后,它做了错误检查。如果没有问题,先 Py_INCREF,增加一下 reference count,这个以后有机会再讲。然后 PUSH 把值压到栈顶。FAST_DISPATCH 就是继续执行下一个 bytecode,还记得外面是一个无限循环吗?这个 LOAD_FAST 其实很简单。认真读的话,你会发现它不是一个很神秘的东西,就是一些很简单的事情。
BINARY_ADD
我们再看 BINARY_ADD 的实现。它之前讲 1 加 1 时也出现过:
case TARGET(BINARY_ADD): {
PyObject *right = POP();
PyObject *left = TOP();
PyObject *sum;
if (PyUnicode_CheckExact(left) &&
PyUnicode_CheckExact(right)) {
sum = unicode_concatenate(tstate, left, right, f, next_instr);
}
else {
sum = PyNumber_Add(left, right);
Py_DECREF(left);
}
Py_DECREF(right);
SET_TOP(sum);
if (sum == NULL)
goto error;
DISPATCH();
}
它也没有很复杂。当你理解虚拟机是 stack-based 之后,它实际上就是 POP 一下,拿到 TOP 的东西,把它俩加起来,然后 SET_TOP。只是中间有一些关于 string 的优化。
RETURN_VALUE
再看 RETURN_VALUE 的实现,就稍微有点不一样了:
case TARGET(RETURN_VALUE): {
retval = POP();
assert(f->f_iblock == 0);
assert(EMPTY());
f->f_state = FRAME_RETURNED;
f->f_stackdepth = 0;
goto exiting;
}
它修改了一些跟 frame 有关的状态,比如把 f_state 变成 FRAME_RETURNED,表示这个 frame 已经结束了。同时最后一行不再是 dispatch,而是 goto exiting。如果你一直在看我的文章,会发现 goto exiting 很眼熟,之前讲 async 时也提到过。goto exiting 就是要退出这个无限循环,准备返回值。
所以大家可以看到,研究 Python 代码在底层究竟是如何工作的,其实不是一件很困难的事。每个人都是有能力完成这件事的。
总结一下,每一次调用函数,或者刚开始运行 Python 的时候,都会建立一个新的 frame。在这个 frame 的环境下,一条一条地运行 bytecode,每条 bytecode 在 C 语言里都有相应的代码去执行。每个 frame 里面,Python 会维护一个 stack,也就是栈。bytecode 和这个栈进行交互,也会和 code object 里保存的那些东西交互,接着进行计算,拿到结果,返回,继续。
如果你对汇编比较熟悉,会发现这些东西跟汇编是一套东西,只不过汇编可以直接在硬件上运行,而 Python 的 bytecode 要运行在 C 语言实现的虚拟机上。就这么简单。
希望这篇文章能弥补我之前几篇文章之间的鸿沟。