Skip to main content

虚拟机

在前几篇文章里,我们经常会提到 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:

LOAD_FAST

把 co_varnames 里第 var_num 个数据压到栈里。这个 code object 知识是不是用上了?

BINARY_ADD

实现 TOS = TOS1 + TOS。TOS 的意思是 top of stack,也就是栈顶:栈顶下面的数据加上栈顶的数据,再把结果放回栈顶。

RETURN_VALUE

把栈顶的数据返回给这个函数的调用者。

所以,当你不确定 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 语言实现的虚拟机上。就这么简单。

希望这篇文章能弥补我之前几篇文章之间的鸿沟。