楔子
上一篇文章我们介绍了虚拟机是怎么执行字节码的,并且还介绍了运行时栈,以及运行时栈的一些 API。相信你对字节码执行的整个流程应该有了清晰的认识,那么接下来我们就深入到源码中,进一步考察执行过程。
源码解析字节码的执行过程
之前说了,虚拟机就是把自己当成一个 CPU,在栈帧中执行字节码。面对不同的字节码指令,执行不同的处理逻辑。
具体实现由 Python/ceval.c 中的 _PyEval_EvalFrameDefault 函数负责,该函数比较长,并且存在很多的宏,我们会进行适当的简化。
_PyEval_EvalFrameDefault 函数的上方定义了一个宏,这里需要解释一下。对于 CPython 而言,一个 Python 函数调用在底层会涉及多个 C 函数调用,而 C 函数在调用时显然也要创建栈帧。
所以整个过程存在两个调用栈,一个是 Python 调用栈,另一个是 C 调用栈。而调用一个 Python 函数,底层的 C 调用栈会消耗 3 个单元。因此不难发现,随着 Python 调用栈在增长的同时,C 调用栈也在增长。
为此 CPython 引入了一个优化,对于递归函数而言,当执行新的递归调用时,C 调用栈不会再增长。换句话说,不会再次调用 _PyEval_EvalFrameDefault 函数,而是通过改变上下文,在同一个 _PyEval_EvalFrameDefault 里面重新开始。
好啦,下面我们就来分析一下这个长得跟巨无霸一样的函数,当然在 3.12 里面它已经不像之前那么大了,因为有一部分代码被拆分出来了。
// Python/ceval.c
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate,
_PyInterpreterFrame *frame,
int throwflag)
{
// 检测线程状态对象是否不为 NULL
// 如果为 NULL,说明 GIL 被释放,而这是不允许的
// 字节码指令必须在当前线程持有 GIL 的情况下执行
_Py_EnsureTstateNotNULL(tstate);
// _PyEval_EvalFrameDefault 的调用次数加 1
CALL_STAT_INC(pyeval_calls);
// 如果使用 "计算跳转",导入静态跳转表
#if USE_COMPUTED_GOTOS
#include "opcode_targets.h"
#endif
// ...
}
由于该函数的代码量较大,并且很多内容都需要花费一定笔墨去解释,所以为了更加清晰,我们分段讲解。先看上面这段代码,里面出现了计算跳转,需要解释一下它是什么意思。
_PyEval_EvalFrameDefault(后续简称为帧评估函数)虽然很复杂,但它核心不难理解,就是循环遍历字节码指令集,处理每一条指令。而当一条指令执行完毕时,虚拟机会有以下三种动作之一:
停止循环、退出帧评估函数,当执行的指令为 RETURN_VALUE、YIELD_VALUE 等。
指令执行过程中出错了,比如执行 GET_ITER 指令,但对象不具备可迭代的性质。执行出错也要退出帧评估函数,然后执行异常处理逻辑(或者直接抛出异常)。
进入下一轮循环,执行下一条指令。
前面两种动作没啥好说的,关键是第三种,如何执行下一条指令。首先虚拟机内部有一个巨型的 switch 语句,伪代码如下:
for (;;) {
// 循环遍历指令集,获取指令和指令参数
uint8_t opcode = ...; // 指令
int oparg = ...; // 指令参数
// 执行对应的处理逻辑
switch (opcode) {
case LOAD_CONST:
处理逻辑;
case LOAD_FAST:
处理逻辑;
case LOAD_FAST:
处理逻辑;
case BUILD_LIST:
处理逻辑;
case DICT_UPDATE:
处理逻辑;
// ...
}
}
一个 case 分支,对应一个字节码指令的实现,由于指令非常多,所以这个 switch 语句也非常庞大。然后遍历出的指令,会进入这个 switch 语句进行匹配,执行相应的处理逻辑。
所以循环遍历 co_code 得到字节码指令,然后交给内部的 switch 语句、执行匹配到的 case 分支,如此周而复始,最终完成了对整个 Python 程序的执行。
其实到这里,你应该已经了解了帧评估函数的整体结构。说白了就是将自己当成一个 CPU,在栈帧中执行一条条指令,而执行过程中所依赖的常量、变量等,则由栈帧的其它字段来维护。因此在虚拟机的执行流程进入了那个巨大的 for 循环,并取出第一条字节码指令交给里面的 switch 语句之后,第一张多米诺骨牌就已经被推倒,命运不可阻挡的降临了。一条接一条的指令如同潮水般涌来,浩浩荡荡,横无际涯。
虽然在概念上很好理解,但很多细节被忽略掉了,本篇文章就将它们深挖出来。还是之前的问题,当一个指令执行完毕时,怎么执行下一条指令。
估计有人对这个问题感到奇怪,在 case 分支的内部加一行 continue 进行下一轮循环不就行了吗?没错,这种做法是行得通的,但存在性能问题。因为 continue 会跳转到 for 循环所在位置,所以遍历出下一条指令之后,会再次进入 switch 语句进行匹配。尽管逻辑上是正确的,但 switch 里面有数百个 case 分支,如果每来一个指令,都要顺序匹配一遍的话,那么效率必然不高。
而事实上整个字节码指令集是已知的,所以不管执行哪个指令,我们都可以提前得知它的下一个指令,只需将指针向后偏移两个字节即可。
那么问题来了,既然知道下一条要执行的指令是什么,那么在当前指令执行完毕时,可不可以直接跳转到下一条指令对应的 case 分支中呢?
答案是可以的,这个过程就叫做计算跳转,通过标签作为值即可实现。关于什么是标签作为值,我们用一段 C 代码解释一下。
#include <stdio.h>
void label_as_value(int jump) {
int num = 4;
void *label;
switch (num) {
case 1:
printf("%d\n", 1);
break;
// 在 case 2 分支里面定义了一个标签叫 two
case 2: two: {
printf("%d\n", 2);
break;
}
// 在 case 3 分支里面定义了一个标签叫 three
case 3: three: {
printf("%d\n", 3);
break;
}
case 4:
printf("%d\n", 4);
// 如果参数 jump 等于 2,保存 two 标签的地址
// 如果参数 jump 等于 3,保存 three 标签的地址
if (jump == 2) label = &&two;
else if (jump == 3) label = &&three;
// 跳转到指定标签
goto *label;
default:
break;
}
}
int main() {
label_as_value(2);
// 4
// 2
label_as_value(3);
// 4
// 3
}
由于变量 num 等于 4,所以会进入 case 4 分支,在里面有一个 goto *label。如果你对 C 不是特别熟悉的话,估计会有些奇怪,觉得不应该是 goto label 吗?如果是 goto label,那么需要显式地定义一个名为 label 的标签,但这里并没有。
我们的目的是跳转到 two 标签或 three 标签,具体跳转到哪一个,则由参数控制。因此可以使用 && 运算符,这是 GCC 的一个扩展特性,叫做标签作为值,它允许我们获取标签的地址作为一个值。
所以在开头声明了一个 void *label,然后让 label 保存标签地址,再通过 goto *label 跳转到指定标签。由于 *label 代表哪个标签是在运行时经过计算才能知晓,因此称为计算跳转(在运行时动态决定跳转目标)。
注意:
goto *&&标签名
属于高级的、非标准的 C 语言用法。
回到源码中来,在 Python/generated_cases.c.h 文件里面,我们可以看到标签的定义。
这个文件里面定义的便是每个指令的处理逻辑,总共 4800 行,之前内嵌在帧评估函数里面,现在单独拆出来了。执行帧评估函数时,直接 #include 进来即可。
另外我们看到每个指令都调用了 TARGET,显然这是一个宏,看一下它长什么样子。
// Python/ceval_macros.h
#define INSTRUCTION_START(op) (frame->prev_instr = next_instr++)
#if USE_COMPUTED_GOTOS
# define TARGET(op) TARGET_##op: INSTRUCTION_START(op);
# define DISPATCH_GOTO() goto *opcode_targets[opcode]
#else
# define TARGET(op) case op: TARGET_##op: INSTRUCTION_START(op);
# define DISPATCH_GOTO() goto dispatch_opcode
#endif
如果将宏展开的话,那么帧评估函数里面的 switch 语句等价于如下。
如果不使用计算跳转,那么展开之后就是一个 switch 语句,里面是每个指令的处理逻辑。而在逻辑的最后,会调用一个 DISPATCHER() 或 DISPATCH_GOTO()。
// Python/ceval_macros.h
// 会依次调用 3 个宏,中间第二个宏可以忽略掉
#define DISPATCH() \
{ \
NEXTOPARG(); \
PRE_DISPATCH_GOTO(); \
DISPATCH_GOTO(); \
}
/*
typedef union {
uint16_t cache;
struct {
uint8_t code;
uint8_t arg;
} op;
} _Py_CODEUNIT;
*/
// next_instr 指向下一条待执行的指令,prev_instr 指向最近一条执行完毕的指令
// 所以 prev_instr 的下一条指令就是 next_instr
// 由于在处理指令时,先执行了 frame->prev_instr = next_instr++;
// 所以调用 NEXTOPARG() 时,next_instr 已经指向了新的待执行的字节码指令
// 这里获取指令和指令参数
#define NEXTOPARG() do { \
_Py_CODEUNIT word = *next_instr; \
opcode = word.op.code; \
oparg = word.op.arg; \
} while (0)
// 跳转
#if USE_COMPUTED_GOTOS
# define DISPATCH_GOTO() goto *opcode_targets[opcode]
#else
# define DISPATCH_GOTO() goto dispatch_opcode
#endif
因此每个指令在执行完毕后,会调用 DISPATCHER_GOTO(),如果没使用计算跳转,那么会直接 goto 到 dispatch_opcode 标签,然后进入 switch。
所以在 CPython 3.12 版本中,switch 外层的 for 循环已经没有了,使用的是 goto,在获取下一条待执行的指令和参数之后,直接 goto 到 switch 语句所在的标签(dispatch_opcode)。当然这和 for 循环本质上没太大差异,因为在获取到新的指令时,都要重新走一遍 switch。
以上是不使用计算跳转,如果使用计算跳转,从图中可以看到是没有 switch 语句的,当然 TARGET 展开之后也不会出现 case 分支。以 TARGET(LOAD_FAST) 为例:
不使用计算跳转,展开之后是 case LOAD_FAST: TARGET_LOAD_FAST:
使用计算跳转,展开之后是 TARGET_LOAD_FAST:
可以看到使用计算跳转之后,留下的只是一堆标签,当然此时有没有 switch 已经无所谓了,重点是 DISPATCHER_GOTO() 之后可以直接跳转到指定位置,不需要挨个比较了。而根据宏定义,最后会 goto 到 *opcode_targets[opcode],这个 opcode_targets 便是一开始导入的静态跳转表。
它定义在 Python/opcode_targets.h 中,我们看一下。
每个指令的处理逻辑会对应一个标签,这些标签的地址全部保存在了数组中,执行帧评估函数时导入进来即可。这里可能有人会问,导入数组时,它里面的标签都还没有定义吧。确实如此,不过没关系,对于 C 来说,标签只要定义了,那么它在函数的任何一个位置都可以使用。
变量 opcode 就是指令(一个 uint8 整数),而最后跳转到了 *opcode_targets[opcode],那么我们有理由相信,指令和 opcode_targets 数组的索引之间存在某种关联。
这种关联也很好想,opcode_targets[opcode] 指向的标签,其内部的逻辑就是用来处理 opcode 指令的,我们来验证一下。
LOAD_CONST 的值是 100,那么 opcode_targets[100] 一定是 &&TARGET_LOAD_CONST。
结果没有问题,其它指令也是一样的,通过计算跳转,可以直接 goto 到指定的标签。
好,我们总结一下,早期的帧评估函数内部有一个巨型的 switch,每来一个指令都要顺序匹配数百个 case 分支,找到符合条件的那一个。尽管这些 case 分支内部也定义了标签,但对实现精确跳转没太大帮助。
而在 3.12 里面引入了计算跳转,此时 switch 已经没有了,只剩下了标签。当然有没有 switch 都无所谓,重点是每个标签的地址被保存在了数组 opcode_targets 中,并且标签在数组中的索引和对应的指令是相等的。
比如下一条要执行的指令是 YIELD_VALUE,它等于 150,那么
opcode_targets[150]
就等于 &&TARGET_YIELD_VALUE,指向的标签内部便是 YIELD_VALUE 的处理逻辑,至于其它指令也是同理。
因此读取完下一条指令之后,就不用像之前一样跳转到开头重新走一遍 switch 了。而是将指令作为索引,从 opcode_targets 拿到标签地址直接跳转即可,并且跳转后的标签内部的逻辑就是用来处理该指令的。
所以底层为每个指令的处理逻辑都定义了一个标签,而标签的地址在数组中的索引,和处理的指令是相等的。
不过要想实现计算跳转,需要 GCC 支持标签作为值这一特性,即 goto *标签地址,至于标签地址是哪一个标签的地址,则在运行时动态计算得出。比如 opcode_targets[opcode] 指向哪个标签无从得知,这取决于 opcode 的值。
goto 标签
:静态跳转,标签需要显式地定义好,跳转位置在编译期间便已经固定。
goto *标签地址
:动态跳转(计算跳转),跳转位置不固定,可以是已有标签中的任意一个。至于具体是哪一个,需要在运行时经过计算才能确定。
关于计算跳转我们就解释完了,当然还解释了很多其它内容,下面继续看源码。
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate,
_PyInterpreterFrame *frame,
int throwflag)
{
_Py_EnsureTstateNotNULL(tstate);
CALL_STAT_INC(pyeval_calls);
#if USE_COMPUTED_GOTOS
#include "opcode_targets.h"
#endif
uint8_t opcode; // 指令
int oparg; // 指令参数
/*
typedef struct _PyCFrame {
struct _PyInterpreterFrame *current_frame;
struct _PyCFrame *previous;
} _PyCFrame;
结构体位于 Include/cpython/pystate.h 中
*/
// _PyCFrame 相当于对 _PyInterpreterFrame 做了一层封装
// 为了方便描述,我们也称 _PyCFrame 实例为栈桢
// cframe 会在 C 语言的调用栈中传递,从而提高性能
_PyCFrame cframe;
// 入口帧
_PyInterpreterFrame entry_frame;
PyObject *kwnames = NULL;
// tstate 表示线程状态对象,tstate->cframe 指向正在执行的栈桢
// 那么显然要将 tstate->cframe 更新为 &cframe
// 不过更新之前,要先将目前的 tstate->cframe 保存起来
// 因为进入到帧评估函数中,说明开启了新的帧,也意味着它成为了上一个帧
_PyCFrame *prev_cframe = tstate->cframe;
cframe.previous = prev_cframe; // 通过 previous 保存上一个帧
tstate->cframe = &cframe; // 指向当前帧
// 对入口帧内部的属性初始化
entry_frame.f_code = tstate->interp->interpreter_trampoline;
entry_frame.prev_instr =
_PyCode_CODE(tstate->interp->interpreter_trampoline);
entry_frame.stacktop = 0;
entry_frame.owner = FRAME_OWNED_BY_CSTACK;
entry_frame.return_offset = 0;
/* Push frame */
entry_frame.previous = prev_cframe->current_frame;
// frame 是 _PyEval_Vector 内部创建的栈桢,也就是当前正在执行的帧
// _PyEval_Vector 创建完栈桢后,会将它作为参数,调用帧评估函数
// 而 frame->previous 同样指向上一个 _PyInterpreterFrame
// 这里将它赋值为 &entry_frame,即入口帧
frame->previous = &entry_frame;
// cframe 和 frame 都可以理解为正在执行的栈桢
// 但前者对后者做了一层封装,这里将 cframe.current_frame 赋值为 frame
cframe.current_frame = frame;
// 判断还能否安全地进行递归调用
// 如果不能,就减少递归计数并跳转到退出逻辑
tstate->c_recursion_remaining -= (PY_EVAL_C_STACK_UNITS - 1);
if (_Py_EnterRecursiveCallTstate(tstate, "")) {
tstate->c_recursion_remaining--;
tstate->py_recursion_remaining--;
goto exit_unwind;
}
// next_instr 刚才说过的,它指向下一条待执行的指令
// 通过不断执行 frame->prev_instr = next_instr++
// 最终完成整个字节码指令集的遍历
_Py_CODEUNIT *next_instr;
// stack_pointer 指向运行时栈的栈顶
// 元素的入栈和出栈,都是通过操作 stack_pointer 实现的
PyObject **stack_pointer;
// 一个宏,负责初始化 next_instr 和 stack_pointer
// 但里面有一个 _PyInterpreterFrame_LASTI 函数需要说一下
// 我们说 prev_instr 指向上一条、或者最近一条执行完毕的字节码指令
// 那么 prev_instr 的偏移量是多少呢?这个还是很简单的
// 用 frame->prev_instr - _PyCode_CODE(f_code) 即可
// 而这个偏移量在以前的版本中,会有一个专门的栈桢字段(f_lasti)保存
#define SET_LOCALS_FROM_FRAME() \
assert(_PyInterpreterFrame_LASTI(frame) >= -1); \
next_instr = frame->prev_instr + 1; \
stack_pointer = _PyFrame_GetStackPointer(frame);
// 开始在栈桢中执行字节码了,但还要做一下检测
// 判断函数的调用层级是否超过了最大深度(默认 1000)
start_frame:
if (_Py_EnterRecursivePy(tstate)) {
goto exit_unwind;
}
// 调用宏 SET_LOCALS_FROM_FRAME,初始化 next_instr 和 stack_pointer
resume_frame:
SET_LOCALS_FROM_FRAME();
// ...
}
这部分代码主要是负责初始化一些属性,其中最重要的两个属性是 next_instr 和 stack_pointer,字节码的遍历由 next_instr 负责,运行时栈的操作由 stack_pointer 负责。
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate,
_PyInterpreterFrame *frame,
int throwflag)
{
// ...
// ...
// 开始进行调度,准备执行字节码
DISPATCH();
handle_eval_breaker:
if (_Py_HandlePending(tstate) != 0) {
goto error;
}
DISPATCH();
{
/* Start instructions */
// 如果不使用计算跳转,那么这里就是带标签的 switch 语句
// 如果使用计算跳转,那么这两行无效
#if !USE_COMPUTED_GOTOS
dispatch_opcode:
switch (opcode)
#endif
{
// 导入 generated_cases.c.h,还记得这个文件吗?里面包含了指令的处理逻辑
// 如果不使用计算跳转,那么宏 TARGET 展开之后会生成一个带标签的 case 语句
// 如果使用计算跳转,那么宏 TARGET 展开之后就只有一个标签
#include "generated_cases.c.h"
// ...
} /* End instructions */
Py_UNREACHABLE();
}
// ...
}
这一部分是虚拟机开始执行字节码,如果程序没有出现错误,那么会将所有字节码指令执行完毕。
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate,
_PyInterpreterFrame *frame,
int throwflag)
{
// ...
{
// ...
// 如果执行时出现 UnboundLocalError,会跳转到此标签
unbound_local_error:
{
format_exc_check_arg(tstate, PyExc_UnboundLocalError,
UNBOUNDLOCAL_ERROR_MSG,
PyTuple_GetItem(frame->f_code->co_localsplusnames, oparg)
);
goto error;
}
// 如果字节码执行时报错了,但运行时栈里面还有元素
// 那么要将运行时栈里的元素弹出
// 当运行时栈里包含 4 个元素时,跳转到此标签
pop_4_error:
STACK_SHRINK(1);
// 当运行时栈里包含 3 个元素时,跳转到此标签
pop_3_error:
STACK_SHRINK(1);
// 当运行时栈里包含 2 个元素时,跳转到此标签
pop_2_error:
STACK_SHRINK(1);
// 当运行时栈里包含 1 个元素时,跳转到此标签
pop_1_error:
STACK_SHRINK(1);
error:
kwnames = NULL;
assert(_PyErr_Occurred(tstate));
assert(frame != &entry_frame);
// 报错时,要生成 traceback
// 关于 traceback,等介绍异常捕获的时候再说
if (!_PyFrame_IsIncomplete(frame)) {
PyFrameObject *f = _PyFrame_GetFrameObject(frame);
if (f != NULL) {
PyTraceBack_Here(f);
}
}
monitor_raise(tstate, frame, next_instr-1);
exception_unwind:
// ...
// 后续介绍
}
// ...
}
以上就是帧评估函数的源码逻辑,因为涉及到后面的内容,所以我们省略掉了一部分。
通过反编译查看字节码
源码部分我们就看完了,可能有小伙伴会觉得有些枯燥吧,但我相信认真读完一定会有很大收获。下面我们实际操作一波,通过反编译一段简单的代码,来观察虚拟机执行字节码的整个过程。
code_string = """
chinese = 89
math = 99
english = 91
avg = (chinese + math + english) / 3
"""
# 将上面的代码以模块的方式进行编译
code_obj = compile(code_string, "...", "exec")
# 查看常量池
print(code_obj.co_consts) # (89, 99, 91, 3, None)
# 查看符号表
print(
code_obj.co_names
) # ('chinese', 'math', 'english', 'avg')
在编译的时候,常量和符号(变量)都会被静态收集起来,然后我们反编译一下看看字节码,直接通过 dis.dis(code_obj) 即可。结果如下:
0 0 RESUME 0
2 2 LOAD_CONST 0 (89)
4 STORE_NAME 0 (chinese)
3 6 LOAD_CONST 1 (99)
8 STORE_NAME 1 (math)
4 10 LOAD_CONST 2 (91)
12 STORE_NAME 2 (english)
5 14 LOAD_NAME 0 (chinese)
16 LOAD_NAME 1 (math)
18 BINARY_OP 0 (+)
22 LOAD_NAME 2 (english)
24 BINARY_OP 0 (+)
28 LOAD_CONST 3 (3)
30 BINARY_OP 11 (/)
34 STORE_NAME 3 (avg)
36 RETURN_CONST 4 (None)
我们从上到下依次解释每条指令都干了什么?
2 LOAD_CONST:表示加载一个常量(地址),并压入运行时栈。后面的指令参数 0 表示从常量池中加载索引为 0 的常量。
4 STORE_NAME:表示将 LOAD_CONST 加载的常量用一个名字绑定起来,放入所在的名字空间中。后面的 0 (chinese) 表示使用符号表中索引为 0 的名字(符号),且名字为 "chinese"。
所以像 chinese = 89 这种简单的赋值语句,会对应两条字节码指令。
然后 6 LOAD_CONST、8 STORE_NAME、10 LOAD_CONST、12 STORE_NAME 的作用显然和上面是一样的,都是加载一个常量,然后和指定的符号绑定起来,并放入名字空间中。
14 LOAD_NAME:加载一个变量,并压入运行时栈。后面的 0 (chinese) 表示加载符号表中索引为 0 的变量的值,然后这个变量叫 chinese。
16 LOAD_NAME:同理,将符号表中索引为 1 的变量的值压入运行时栈,并且变量叫 math。此时栈里面有两个元素,从栈底到栈顶分别是 chinese 和 math。
18 BINARY_OP:将上面两个变量从运行时栈弹出,然后执行加法操作,并将结果压入运行时栈。
22 LOAD_NAME:将符号表中索引为 2 的变量 english 的值压入运行时栈,此时栈里面有两个元素,从栈底到栈顶分别是 chinese + math 的返回结果和 english。
24 BINARY_OP:将运行时栈里的两个元素弹出,然后执行加法操作,并将结果压入运行时栈。此时栈里面有一个元素,就是 chinese + math + english 的运算结果。
28 LOAD_CONST:将常量 3 压入运行时栈,此时栈里面有两个元素。
30 BINARY_OP:将运行时栈里的两个元素弹出,然后执行除法操作,并将结果压入运行时栈,此时栈里面有一个元素。
34 STORE_NAME:将元素从运行时栈里面弹出,并用符号表中索引为 3 的符号 avg 和它绑定起来,然后放在名字空间中。
36 RETURN_CONST:将常量 None 压入运行时栈,然后弹出并返回。
所以 Python 虚拟机就是把自己想象成一颗 CPU,在栈帧中一条条执行字节码指令,当指令执行完毕或执行出错时,停止执行。
我们通过几张图展示一下上面的过程,为了阅读方便,这里将相应的源代码再贴一份:
chinese = 89
math = 99
english = 91
avg = (chinese + math + english) / 3
上面的代码位于最外层的模块中,由于模块也有自己的作用域,并且是全局作用域,所以虚拟机也会为它创建栈帧。而在代码还没有执行的时候,栈帧就已经创建好了,整个布局如下。
f_localsplus 下面的箭头方向,代表运行时栈从栈底到栈顶的方向。
这里再强调一个重要的知识点,我们看到栈帧里面有一个 f_localsplus 属性,它是一个数组。虽然声明的时候写着长度为 1,但实际使用时,长度不受限制,和 Go 语言不同,C 数组的长度不属于类型的一部分。
所以 f_localsplus 是一个动态内存,运行时栈所需要的空间就存储在里面,但这块内存并不光给运行时栈使用,它被分成了四块。
函数的局部变量是静态存储的,那么都存在哪呢?答案是在 f_localsplus 里面,而且是开头的位置。在获取的时候直接基于索引操作即可,因此速度会更快。所以源码内部还有两个宏:
函数在编译的时候就知道每个局部变量在 f_localsplus 中的索引,所以直接通过索引操作即可,关于局部变量的具体操作细节,后续再聊。
然后 cell 变量和 free 变量是用来处理闭包的,而 f_localsplus 的最后一块内存则用于运行时栈。
所以 f_localsplus 是一个数组,它是一段连续内存,只不过从逻辑上讲,它被分成了四份,每一份用在不同的地方。但它们整体是连续的,都是数组的一部分。但当前示例中的代码是以模块的方式编译的,里面所有的变量都是全局变量,而且也不涉及闭包啥的,所以这里就把 f_localsplus 理解为运行时栈即可。
接下来就开始执行字节码了,虚拟机会执行:2 LOAD_CONST,该指令表示将常量加载进运行时栈,而要加载的常量在常量池中的索引,由指令参数表示。
在源码中,指令对应的变量是 opcode,指令参数对应的变量是 oparg
// Python/generated_cases.c.h
TARGET(LOAD_CONST) {
PREDICTED(LOAD_CONST);
PyObject *value;
#line 204 "Python/bytecodes.c"
// 从常量池中加载索引为 oparg 的常量
value = GETITEM(frame->f_code->co_consts, oparg);
// 增加引用计数
Py_INCREF(value);
#line 114 "Python/generated_cases.c.h"
// stack_pointer++,为入栈的元素留出一个空间
// 我们上一篇专门介绍了这些运行时栈的 API
STACK_GROW(1);
// 将栈顶元素设置为 value
stack_pointer[-1] = value;
// STACK_GROW(1) 和 stack_pointer[-1] = value 组合起来等价于 PUSH(value)
// 调用 DISPATCHER,读取下一条指令,并跳转到指定位置进行处理
DISPATCH();
}
// Python/ceval_macros.h
static inline PyObject *
GETITEM(PyObject *v, Py_ssize_t i) {
// 从元组 v 中获取索引为 i 的元素
assert(PyTuple_Check(v));
assert(i >= 0);
assert(i < PyTuple_GET_SIZE(v));
return PyTuple_GET_ITEM(v, i);
}
该指令的参数为 0,所以会将常量池中索引为 0 的元素 89 压入运行时栈,执行完之后,栈帧的布局就变成了下面这样:
接着虚拟机执行 4 STORE_NAME 指令,从符号表中获取索引为 0 的符号、即 chinese。然后将栈顶元素 89 弹出,再将符号 chinese 和整数对象 89 绑定起来保存到 local 名字空间中。
TARGET(STORE_NAME) {
// 获取运行时栈的栈顶元素,显然是上一步压入的 89
PyObject *v = stack_pointer[-1];
#line 1013 "Python/bytecodes.c"
// 从符号表中加载索引为 oparg 的符号
// 符号本质上就是一个 PyUnicodeObject 对象
// 这里就是字符串 "chinese"
PyObject *name = GETITEM(frame->f_code->co_names, oparg);
// #define LOCALS() frame->f_locals
// 获取名字空间(namespace)
PyObject *ns = LOCALS();
int err;
if (ns == NULL) {
// 如果没有名字空间则报错,设置异常
_PyErr_Format(tstate, PyExc_SystemError,
"no locals found when storing %R", name);
#line 1405 "Python/generated_cases.c.h"
Py_DECREF(v);
#line 1020 "Python/bytecodes.c"
if (true) goto pop_1_error;
}
// 将符号和对象绑定起来放在名字空间 ns 中
// 名字空间是一个字典,PyDict_CheckExact 负责检测 ns 是否为字典
if (PyDict_CheckExact(ns))
err = PyDict_SetItem(ns, name, v);
else
err = PyObject_SetItem(ns, name, v);
#line 1414 "Python/generated_cases.c.h"
// 对象的引用计数减 1
Py_DECREF(v);
#line 1027 "Python/bytecodes.c"
if (err) goto pop_1_error;
#line 1418 "Python/generated_cases.c.h"
// 栈收缩,v = stack_pointer[-1] 和 STACK_SHRINK(1)
// 两者组合起来就等价于 v = POP(),即弹出运行时栈的栈顶元素
STACK_SHRINK(1);
DISPATCH();
}
执行完之后,栈帧的布局就变成了下面这样:
此时运行时栈为空,local 名字空间多了个键值对。
同理剩余的两个赋值语句也是类似的,只不过指令参数不同,比如 8 STORE_NAME 加载的是符号表中索引为 1 的符号,12 STORE_NAME 加载的是符号表中索引为 2 的符号,分别是 math 和 english。执行完之后,栈桢的布局如下:
然后 14 LOAD_NAME 和 16 LOAD_NAME 负责将符号表中索引为 0 和 1 的变量的值压入运行时栈:
TARGET(LOAD_NAME) {
PyObject *v;
#line 1236 "Python/bytecodes.c"
// 获取全局名字空间,对于模块来说
// f->f_locals 和 f->f_globals 指向同一个字典
PyObject *mod_or_class_dict = LOCALS();
if (mod_or_class_dict == NULL) {
_PyErr_SetString(tstate, PyExc_SystemError,
"no locals found");
if (true) goto error;
}
// 从符号表 co_names 中加载索引为 oparg 的符号
// 但是注意:全局变量是通过字典存储的
// 所以这里的 name 只是一个字符串罢了,比如 "chinese"
// 然后还要再根据这个字符串从字典里面查找对应的 value
PyObject *name = GETITEM(frame->f_code->co_names, oparg);
// 根据 name 从字典中获取 value
if (PyDict_CheckExact(mod_or_class_dict)) {
v = PyDict_GetItemWithError(mod_or_class_dict, name);
if (v != NULL) {
Py_INCREF(v);
}
else if (_PyErr_Occurred(tstate)) {
goto error;
}
}
// ...
#line 1763 "Python/generated_cases.c.h"
// 将变量的值压入运行时栈
STACK_GROW(1);
stack_pointer[-1] = v;
DISPATCH();
}
上面两条指令执行完之后,栈帧的布局就变成了下面这样:
接下来执行 18 BINARY_OP,它会将栈里的两个元素弹出,然后执行加法操作,最后再将结果入栈。
TARGET(BINARY_OP) {
PREDICTED(BINARY_OP);
// 获取栈里的两个元素
PyObject *rhs = stack_pointer[-1];
PyObject *lhs = stack_pointer[-2];
// 计算结果
PyObject *res;
// ...
// binary_ops[oparg] 会拿到对应的函数指针(这里是加法)
// 然后传入 lhs 和 rhs 进行计算
res = binary_ops[oparg](lhs, rhs);
#line 4663 "Python/generated_cases.c.h"
// 减少引用计数
Py_DECREF(lhs);
Py_DECREF(rhs);
#line 3384 "Python/bytecodes.c"
if (res == NULL) goto pop_2_error;
#line 4668 "Python/generated_cases.c.h"
// 栈里面有两个元素,应该将它们弹出,然后再将 res 入栈
// 但事实上只需弹出一个,然后再用 res 将栈顶元素替换掉即可
STACK_SHRINK(1);
stack_pointer[-1] = res;
next_instr += 1;
DISPATCH();
}
BINARY_OP 指令执行完之后,栈帧的布局就变成了下面这样:
然后 22 LOAD_NAME 负责将符号表中索引为 2 的变量 english 的值压入运行时栈;而指令 24 BINARY_OP 则是继续执行加法操作,并将结果设置在栈顶;然后 28 LOAD_CONST 将常量 3 再压入运行时栈;接着再执行 30 BINARY_OP,此时对应的是除法操作。
这四条指令执行时,运行时栈变化如下:
此时运行时栈里面只剩下一个元素 93.0,然后 34 STORE_NAME 将栈顶元素 93.0 弹出,并将符号表中索引为 3 的符号 avg 和它绑定起来,放到名字空间中。因此最终栈帧关系图如下:
以上就是虚拟机对这几行代码的执行流程,整个过程就像 CPU 执行指令一样。
我们再用 Python 代码描述一遍上面的逻辑:
# LOAD_CONST 将 89 压入栈中
# STORE_NAME 将 89 从栈中弹出
# 并将符号 "chinese" 和 89 绑定起来,放在名字空间中
chinese = 89
print(
{k: v for k, v in locals().items() if not k.startswith("__")}
) # {'chinese': 89}
math = 99
print(
{k: v for k, v in locals().items() if not k.startswith("__")}
) # {'chinese': 89, 'math': 99}
english = 91
print(
{k: v for k, v in locals().items() if not k.startswith("__")}
) # {'chinese': 89, 'math': 99, 'english': 91}
avg = (chinese + math + english) / 3
print(
{k: v for k, v in locals().items() if not k.startswith("__")}
) # {'chinese': 89, 'math': 99, 'english': 91, 'avg': 93.0}
现在你是不是对虚拟机执行字节码有一个更深的了解了呢?当然字节码指令有很多,不止我们上面看到的那几个。你可以随便写一些代码,然后分析一下它的字节码指令是什么样的。
小结
到此,我们就深入源码,考察了虚拟机执行字节码的流程,帧评估函数虽然很长,也有那么一些复杂,但是核心逻辑不难理解。就是把自己当成一颗 CPU,在栈帧中执行指令。
下一篇文章我们来介绍一下常见的几个指令,并探讨不同的变量赋值语句的背后原理。