楔子
当解释器启动后,首先会进行运行时环境的初始化。注意这里的运行时环境,它和之前说的执行环境是不同的概念。运行时环境是一个全局的概念,而执行环境是一个栈帧。
关于运行时环境的初始化是一个很复杂的过程,涉及到 Python 进程、线程的创建,类型对象的完善等非常多的内容,我们暂时先不讨论。这里就假设初始化动作已经完成,我们已经站在了虚拟机的门槛外面,只需要轻轻推动第一张骨牌,整个执行过程就像多米诺骨牌一样,一环扣一环地展开。
在介绍字节码的时候我们说过,解释器可以看成是:编译器+虚拟机,编译器负责将源代码编译成 PyCodeObject 对象,而虚拟机则负责执行。整个过程如下:
所以我们的重点就是虚拟机是怎么执行 PyCodeObject 对象的?整个过程是什么,掌握了这些,你对虚拟机会有一个更深的理解。
虚拟机的运行框架
在介绍栈帧的时候我们说过,Python 是一门动态语言,一个变量指向什么对象需要在运行时才能确定,这些信息不可能静态存储在 PyCodeObject 对象中。
所以虚拟机在运行时会基于 PyCodeObject 对象动态创建出一个栈帧对象,然后在栈帧里面执行字节码。而创建栈帧,主要使用以下两个函数:
// 基于 PyCodeObject、全局名字空间、局部名字空间,创建栈帧 // 参数非常简单,所以它一般适用于模块这种参数不复杂的场景 // 我们说模块也会对应一个栈帧,并且它位于栈帧链的最顶层 PyObject * PyEval_EvalCode(PyObject *co, PyObject *globals, PyObject *locals); // 它相比 PyEval_EvalCode 多了很多的参数 // 比如里面有位置参数以及个数,关键字参数以及个数 // 还有默认参数以及个数,闭包等等,显然它用于函数等复杂场景 PyObject * PyEval_EvalCodeEx(PyObject *_co, PyObject *globals, PyObject *locals, PyObject *const *args, int argcount, PyObject *const *kws, int kwcount, PyObject *const *defs, int defcount, PyObject *kwdefs, PyObject *closure);
但这两个函数都属于高层的封装,它们最终都会调用 _PyEval_EvalCodeWithName 函数。这个函数内部的逻辑我们就不看了,只需要知道它在执行完毕之后栈帧就创建好了,而栈帧才是我们的重点,因为代码在执行期间所依赖的上下文信息全部由栈帧来维护。
一旦栈帧对象初始化完毕,那么就要进行处理了,处理的时候会调用以下两个函数。
PyObject * PyEval_EvalFrame(PyFrameObject *f); PyObject * PyEval_EvalFrameEx(PyFrameObject *f, int throwflag);
当然啦,上面这两个函数同样属于高层的封装,最终会调用 _PyEval_EvalFrameDefault 函数,虚拟机就是通过该函数来完成字节码的执行。
PyObject* _Py_HOT_FUNCTION _PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag);
到目前为止总共出现了 6 个函数,用一张图来描述一下它们的关系:
所以 _PyEval_EvalFrameDefault 函数是虚拟机运行的核心,并且代码量很大。
可以看到这一个函数大概在 3100 行左右,不过也仅仅是代码量大而已,因为它的逻辑很好理解。
// 源代码位于 Python/ceval.c 中 PyObject* _Py_HOT_FUNCTION _PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag) { //...... co = f->f_code; names = co->co_names; consts = co->co_consts; fastlocals = f->f_localsplus; freevars = f->f_localsplus + co->co_nlocals; next_instr = first_instr; if (f->f_lasti >= 0) { assert(f->f_lasti % sizeof(_Py_CODEUNIT) == 0); next_instr += f->f_lasti / sizeof(_Py_CODEUNIT) + 1; } // 栈顶指针 stack_pointer = f->f_stacktop; assert(stack_pointer != NULL); f->f_stacktop = NULL; //...... }
该函数首先会初始化一些变量,PyCodeObject 对象包含的信息不用多说,还有一个重要的动作就是对指针 stack_pointer 进行初始化。stack_pointer 指向运行时栈的栈顶,关于运行时栈可能有人暂时还不理解它是做什么的,别急,马上你就知道它的作用了。
栈帧对象有两个重要字段是关于运行时栈的,f_stacktop 字段指向运行时栈的栈顶,f_valuestack 字段指向运行时栈的栈底。
所以对 stack_pointer 初始化的时候,将它的值初始化为 f->f_stacktop,让它指向运行时栈的栈顶。但操作运行时栈是通过 stack_pointer 操作的,随着元素的添加和删除,栈顶位置会变,所以后续它反过来还要再赋值给 f_stacktop。
然后栈帧中的 f_code 就是 PyCodeObject 对象,该对象的 co_code 字段则保存着字节码指令序列。而虚拟机执行字节码就是从头到尾遍历整个 co_code,对指令逐条执行的过程。
估计有人对栈帧和 PyCodeObject 对象的底层结构已经记不太清了,这里为了方便后续内容的理解,我们将它们的结构再展示一下。
所以字节码也叫作指令序列,它就是一个普普通通的 bytes 对象,对于 C 而言则是一个字符数组,一条指令就是一个字符、或者说一个整数。而在遍历的时候会使用以下两个变量:
- first_instr:永远指向字节码指令序列的第一条字节码指令;
- next_instr:永远指向下一条待执行的字节码指令;
当然别忘记栈帧的 f_lasti 成员,它记录了上一条已经执行过的字节码指令的偏移量。
多说一句,生成器之所以能够从中断的位置恢复执行,就是因为 f_lasti 记录了上一条执行的字节码指令的位置。
那么这个动作是如何一步步完成的呢?其实就是一个 for 循环加上一个巨大的 switch case 结构。
PyObject* _Py_HOT_FUNCTION _PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag) { //...... co = f->f_code; names = co->co_names; consts = co->co_consts; fastlocals = f->f_localsplus; freevars = f->f_localsplus + co->co_nlocals; //...... // 死循环 for (;;) { if (_Py_atomic_load_relaxed(eval_breaker)) { // 不断地读取 co->co_code 中下一条待执行的字节码指令 opcode = _Py_OPCODE(*next_instr); // opcode 就是字节码指令序列中的每一条指令 // 指令有哪些都定义在 Include/opcode.h 中 if (opcode == SETUP_FINALLY || opcode == SETUP_WITH || opcode == BEFORE_ASYNC_WITH || opcode == YIELD_FROM) { goto fast_next_opcode; } fast_next_opcode: // ...... // 判断该指令属于什么操作,然后执行相应的逻辑 switch (opcode) { // 加载一个局部变量 case TARGET(LOAD_FAST): // ...... break; // 加载一个常量 case TARGET(LOAD_CONST): // ...... break; // ...... } } }
在这个执行架构中,对字节码的遍历是通过宏来实现的:
#define INSTR_OFFSET() \ (sizeof(_Py_CODEUNIT) * (int)(next_instr - first_instr)) #define NEXTOPARG() do { \ _Py_CODEUNIT word = *next_instr; \ opcode = _Py_OPCODE(word); \ oparg = _Py_OPARG(word); \ next_instr++; \ } while (0)
首先每条字节码指令都会带有一个参数,co_code 中索引为 0 2 4 6 8... 的整数便是指令,索引为 1 3 5 7 9... 的整数便是参数。所以 co_code 里面并不全是字节码指令,每条指令后面都还跟着一个参数。因此 next_instr 每次向后移动两个字节,便可跳到下一条指令。
next_instr 和 first_instr 都是 _Py_CODEUNIT * 类型的变量,这个 _Py_CODEUNIT 是一个 uint16_t。所以只要执行 next_instr++,便可向后移动两字节,跳到下一条指令。
我们再看一下上面的宏,INSTR_OFFSET 计算的显然就是下一条待执行的指令和第一条指令之间的偏移量;然后是 NEXTOPARG,里面的变量 word 就是待执行的指令。
当然,由于 word 占两字节,所以也包括了参数。其中 word 的前 8 位是指令 opcode,后 8 位是参数 oparg。然后在解析出来指令以及参数之后,再执行 next_instr++,跳到下一条指令。
而接下来就要执行上面刚解析出来的字节码指令了,会利用 switch 语句对指令进行判断,根据判断的结果选择不同的 case 分支。
每一个 case 分支,对应一个字节码指令的实现,不同的指令执行不同的 case 分支。所以这个 switch case 语句非常的长,函数总共 3000 多行,这个 switch 就占了2400行。因为指令非常多,比如:LOAD_CONST, LOAD_NAME, YIELD_FROM等等,而每一个指令都要对应一个 case 分支。
然后当匹配到的 case 分支执行完毕时,说明当前的这一条字节码指令就执行完毕了,那么虚拟机的执行流程会跳转到标签 fast_next_opcode 所在位置,或者 for 循环所在位置。但不管如何,虚拟机接下来的动作就是获取下一条字节码指令和指令参数,完成对下一条指令的执行。
所以通过 for 循环一条一条遍历 co_code 中包含的所有字节码指令,然后交给内部的 switch 语句、选择不同的 case 分支进行执行,如此周而复始,最终完成了对整个 Python 程序的执行。
尽管目前只是简单的分析,但相信你也能大体地了解 Python 执行引擎的整体结构。说白了 Python 虚拟机就是将自己当成一个 CPU,在栈帧中一条条的执行指令,而执行过程中所依赖的常量、变量等,则由栈帧的其它成员来维护。
因此在虚拟机的执行流程进入了那个巨大的 for 循环,并取出第一条字节码指令交给里面的 switch 语句之后,第一张多米诺骨牌就已经被推倒,命运不可阻挡的降临了。一条接一条的指令如同潮水般涌来,浩浩荡荡,横无际涯。
运行时栈的一些 API
这里先来简单介绍一下运行时栈,它是参数的容身之所,比如虚拟机在执行 a + b 的时候,知道这是一个加法操作。但在执行加法的时候,加号两边的值是多少,它要怎么获取呢?这时候就需要一个栈来专门保存相应的参数。
在执行加法之前,先将 a 和 b 压入栈中,然后执行加法的时候,再将 a 和 b 从栈里面弹出来即可。现在有一个印象,一会儿我们通过反编译查看字节码指令的时候,就一切都清晰了。
然后再来看看运行时栈相关的一些 API。
API 非常多,但操作运行时栈都是通过操作 stack_pointer 实现的。假设运行时栈内部有三个元素,从栈底到栈顶分别是整数 1、2、3,那么运行时栈的结构就是下面这样。
然后看一下这些和运行时栈相关的 API 都是干嘛的。
STACK_LEVEL():
#define STACK_LEVEL() \ ((int)(stack_pointer - f->f_valuestack))
返回运行时栈的元素数量。
EMPTY():
#define EMPTY() (STACK_LEVEL() == 0)
判断运行时栈是否为空。
TOP():
#define TOP() (stack_pointer[-1])
查看当前运行时栈的栈顶元素。
SECOND():
#define SECOND() (stack_pointer[-2])
查看从栈顶元素开始的第二个元素,所以随着元素不断添加,栈顶元素也在不断发生变化,而 stack_pointer 也在不断变化。
THIRD():
#define THIRD() (stack_pointer[-3])
查看从栈顶元素开始的第三个元素。
FOURTH():
#define FOURTH() (stack_pointer[-4])
查看从栈顶元素开始的第四个元素。
PEEK(n):
#define PEEK(n) (stack_pointer[-(n)])
查看从栈顶元素开始的第 n 个元素。
SET_TOP(v):
#define SET_TOP(v) (stack_pointer[-1] = (v))
将当前运行时栈的栈顶元素设置成 v,同理还有 SET_SECOND,SET_THIRD,SET_FOURTH,SET_VALUE。
PUSH(v):
往运行时栈中压入一个元素。
// 将 stack_pointer 设置成 v,然后再执行自增操作 #define BASIC_PUSH(v) (*stack_pointer++ = (v)) // 也是调用了 BASIC_PUSH,但做了一层检测 // co_stacksize 表示运行时栈的大小,STACK_LEVEL() 不能超过它 #define PUSH(v) { (void)(BASIC_PUSH(v), \ lltrace && prtrace(tstate, TOP(), "push")); \ assert(STACK_LEVEL() <= co->co_stacksize); }
假设当前运行时栈有 1、2、3 总共三个元素,我们往栈里面压入一个元素 4,那么运行时栈就会变成下面这个样子。
Python 的变量都是一个指针,所以 stack_pointer 是一个二级指针,它永远指向栈顶位置,只不过栈顶位置会变。
POP(v):
从运行时栈弹出一个元素,注意它和 TOP 的区别,TOP 是返回栈顶元素,但不弹出。
// 将 stack_pointer 先执行自减操作,然后解引用 #define BASIC_POP() (*--stack_pointer) #define POP() ((void)(lltrace && prtrace(tstate, TOP(), "pop")), \ BASIC_POP())
假设当前运行时栈有 1、2、3 总共三个元素,我们弹出一个元素,那么运行时栈就会变成下面这个样子。
stack_pointer 指向栈顶位置,所以它向栈底移动一个位置,就相当于元素被弹出了。
通过反编译查看字节码
我们写一段简单的代码,然后反编译,看看虚拟机是如何执行字节码的。
code = """\ chinese = 89 math = 99 english = 91 avg = (chinese + math + english) / 3 """ # 将上面的代码以模块的方式进行编译 co = compile(code, "...", "exec") # 查看常量池 print(co.co_consts) # (89, 99, 91, 3, None) # 查看符号表 print( co.co_names ) # ('chinese', 'math', 'english', 'avg')
在编译的时候,常量和符号(变量)都会被静态收集起来,然后我们反编译一下看看字节码,直接通过 dis.dis(co) 即可。结果如下:
1 0 LOAD_CONST 0 (89) 2 STORE_NAME 0 (chinese) 2 4 LOAD_CONST 1 (99) 6 STORE_NAME 1 (math) 3 8 LOAD_CONST 2 (91) 10 STORE_NAME 2 (english) 4 12 LOAD_NAME 0 (chinese) 14 LOAD_NAME 1 (math) 16 BINARY_ADD 18 LOAD_NAME 2 (english) 20 BINARY_ADD 22 LOAD_CONST 3 (3) 24 BINARY_TRUE_DIVIDE 26 STORE_NAME 3 (avg) 28 LOAD_CONST 4 (None) 30 RETURN_VALUE
解释一下每一列的含义:
- 第一列是源代码的行号;
- 第二列是指令的偏移量,或者说该指令在整个字节码指令序列中的索引。因为每条指令后面都跟着一个参数,所以偏移量是 0 2 4 6 8 ...;
- 第三列是字节码指令,简称指令,它们在宏定义中代表整数;
- 第四列是字节码指令参数,简称指令参数、或者参数,不同的指令参数的含义不同;
- 第五列是 dis 模块给我们额外提供的信息,一会说;
我们从上到下依次解释每条指令都干了什么?
0 LOAD_CONST:表示加载一个常量(指针),并压入运行时栈。后面的指令参数 0 表示从常量池中加载索引为 0 的常量,至于 89 则表示加载的常量是 89。所以最后面的括号里面的内容实际上起到的是一个提示作用,告诉你加载的对象是什么。
2 STORE_NAME:表示将 LOAD_CONST 加载的常量用一个名字绑定起来,放在所在的名字空间中。后面的 0 (chinese) 则表示使用符号表中索引为 0 的名字(符号),且名字为 "chinese"。
所以像 chinese = 89 这种简单的赋值语句,会对应两条字节码指令。
然后 4 LOAD_CONST、6 STORE_NAME 和 8 LOAD_CONST、10 STORE_NAME 的作用显然和上面是一样的,都是加载一个常量,然后将某个符号和常量绑定起来,并放在名字空间中。
12 LOAD_NAME:加载一个变量,并压入运行时栈。而后面的 0 (chinese) 表示加载符号表中索引为 0 的变量的值,然后这个变量叫 chinese。14 LOAD_NAME 也是同理,将符号表中索引为 1 的变量的值压入运行时栈,并且变量叫 math。此时栈里面有两个元素,从栈底到栈顶分别是 chinese 和 math。
16 BINARY_ADD:将上面两个变量从运行时栈弹出,然后执行加法操作,并将结果压入运行时栈。
18 LOAD_NAME:将符号表中索引为 2 的变量 english 的值压入运行时栈,此时栈里面有两个元素,从栈底到栈顶分别是 chinese + math 的返回结果和 english。
20 BINARY_ADD:将运行时栈里的两个元素弹出,然后执行加法操作,并将结果压入运行时栈。此时栈里面有一个元素,就是 chinese + math + english 的返回结果。
22 LOAD_CONST:将常量 3 压入运行时栈,此时栈里面有两个元素;
22 BINARY_TRUE_DIVIDE:将运行时栈里的两个元素弹出,然后执行除法操作,并将结果压入运行时栈,此时栈里面有一个元素;
24 STORE_NAME:将元素从运行时栈里面弹出,并用符号表中索引为 3 的变量 avg 和它绑定起来,然后放在名字空间中。
28 LOAD_CONST:将常量 None 压入运行时栈,然后通过 30 RETURN_VALUE 将其从栈中弹出,然后返回。
所以 Python 虚拟机就是把自己想象成一颗 CPU,在栈帧中一条条执行字节码指令,当指令执行完毕或执行出错时,停止执行。
我们通过几张图展示一下上面的过程,为了阅读方便,这里将相应的源代码再贴一份:
chinese = 89 math = 99 english = 91 avg = (chinese + math + english) / 3
我们说模块也有自己的作用域,并且是全局作用域,所以虚拟机也会为它创建栈帧。而在代码还没有执行的时候,栈帧就已经创建好了,整个布局如下。
这里补充一个知识点,非常重要,首先我们看到栈帧里面有一个 f_localsplus 属性,它是一个数组。虽然声明的时候写着长度为 1,但实际使用时,长度不受限制,和 Go 语言不同,C 数组的长度不属于类型的一部分。
所以 f_localsplus 是一个动态内存,运行时栈所需要的空间就存储在里面。但这块内存并不光给运行时栈使用,它被分成了四块。
函数的局部变量是静态存储的,那么都存在哪呢?答案是在 f_localsplus 里面,而且是开头的位置。在获取的时候直接基于索引操作即可,因此速度会更快。所以源码内部还有两个宏:
fastlocals 就是栈帧的 f_localsplus,而函数在编译的时候就知道某个局部变量在 f_localsplus 中的索引,所以通过 GETLOCAL 获取即可。同理 SETLOCAL 则是创建一个局部变量。
至于 cell 对象和 free 对象则是用来处理闭包的,而 f_localsplus 的最后一块内存则用于运行时栈。
所以 f_localsplus 是一个数组,它是一段连续内存,只不过从逻辑上讲,它被分成了四份,每一份用在不同的地方。但它们整体是连续的,都是数组的一部分。按照新一团团长丁伟的说法:彼此是鸡犬相闻,但又老死不相往来。
但我们当前是以模块的方式编译的,里面所有的变量都是全局变量,而且也不涉及闭包啥的,所以这里就把 f_localsplus 理解为运行时栈即可。
接下来就开始执行字节码了,next_instr 指向下一条待执行的字节码指令,显然初始状态下,下一条待执行的指令就是第一条指令。
于是虚拟机开始加载:0 LOAD_CONST,该指令表示将常量加载进运行时栈,而要加载的常量在常量池中的索引,由指令参数表示。
在源码中,指令对应的变量是 opcode,指令参数对应的变量是 oparg
// 代码位于 Python/ceval.c 中 case TARGET(LOAD_CONST): { // 调用元组的 GETITEM 方法 // 从常量池中加载索引为 oparg 的对象(常量) // 当然啦,这里为了方便称其为对象,但其实是指向对象的指针 PREDICTED(LOAD_CONST); PyObject *value = GETITEM(consts, oparg); // 增加引用计数 Py_INCREF(value); // 压入运行时栈 PUSH(value); FAST_DISPATCH(); }
该指令的参数为 0,所以会将常量池中索引为 0 的元素 89 压入运行时栈,执行完之后,栈帧的布局就变成了下面这样:
f_localsplus 下面的箭头方向,代表运行时栈从栈底到栈顶的方向。
接着虚拟机执行 2 STORE_NAME 指令,从符号表中获取索引为 0 的符号、即 chinese。然后将栈顶元素 89 弹出,再将符号 chinese 和整数对象 89 绑定起来保存到 local 名字空间中。
case TARGET(STORE_NAME): { // 从符号表中加载索引为 oparg 的符号 // 符号本质上就是一个 PyUnicodeObject 对象 // 这里就是字符串 "chinese" PyObject *name = GETITEM(names, oparg); // 从运行时栈的栈顶弹出元素 // 显然是上一步压入的 89 PyObject *v = POP(); // 获取名字空间 namespace PyObject *ns = f->f_locals; int err; if (ns == NULL) { // 如果没有名字空间则报错,设置异常 // 这个 tstate 是和线程密切相关的 _PyErr_Format(tstate, PyExc_SystemError, "no locals found when storing %R", name); Py_DECREF(v); goto error; } // 将符号和对象绑定起来放在 ns 中 // 名字空间是一个字典,PyDict_CheckExact 则检测 ns 是否为字典 if (PyDict_CheckExact(ns)) // PyDict_CheckExact(ns) 类似于 type(ns) is dict // 除此之外,还有 PyDict_Check(ns) // 它类似于 isinstance(ns, dict),检测标准相对要宽松一些 // 然后将键值对 "chinese": 89 设置到字典中 err = PyDict_SetItem(ns, name, v); else // 走到这里说明 type(ns) 不是 dict,那么它应该继承 dict // 设置元素 err = PyObject_SetItem(ns, name, v); // 对象的引用计数减 1,因为从运行时栈中弹出了 Py_DECREF(v); // 如果 err != 0,证明设置元素出错了,跳转至 error 标签 if (err != 0) goto error; DISPATCH(); }
执行完之后,栈帧的布局就变成了下面这样:
此时运行时栈为空,local 名字空间多了个键值对。
同理剩余的两个赋值语句也是类似的,只不过指令参数不同,比如 6 STORE_NAME 加载的是符号表中索引为 1 的符号,8 STORE_NAME 加载的是符号表中索引为 2 的符号,分别是 math 和 english。
然后 12 LOAD_NAME 和 14 LOAD_NAME 负责将符号表中索引为 0 和 1 的变量的值压入运行时栈:
case TARGET(LOAD_NAME): { // 从符号表 co_names 中加载索引为 oparg 的变量 // 但是注意:全局变量是通过字典存储的 // 所以这里的 name 只是一个字符串罢了,比如 "chinese" // 然后还要再根据这个字符串从字典里面查找对应的 value PyObject *name = GETITEM(names, oparg); // 对于模块来说,f->f_locals 和 f->f_globals 指向同一个字典 PyObject *locals = f->f_locals; PyObject *v; // .... if (PyDict_CheckExact(locals)) { // 根据 name 获取 value // 所以 print(chinese) 本质上就是下面这样 // print(locals["chinese"]) v = PyDict_GetItemWithError(locals, name); if (v != NULL) { Py_INCREF(v); } else if (_PyErr_Occurred(tstate)) { goto error; } } else { // ... } // ... // 将符号表中索引为 oparg 的变量的值 // 压入运行时栈 PUSH(v); DISPATCH(); }
上面两条指令执行完之后,栈帧的布局就变成了下面这样:
接下来执行 16 BINARY_ADD,它会将栈里的两个元素弹出,然后执行加法操作,最后再将结果入栈。
当然上面这种说法是为了方便理解,其实虚拟机真正执行的时候,只会弹出一个元素,而另一个元素只是使用 TOP() 进行查看,但不弹出。将结果计算完毕之后,再将栈顶元素替换掉。
所以本质上,和弹出两个元素、再将计算结果入栈是一样的。
case TARGET(BINARY_ADD): { // 从栈顶弹出元素,这里是 99(变量 math), PyObject *right = POP(); // math 弹出之后,chinese 就成为了新的栈顶元素 // 这里的 TOP() 则是获取栈顶元素 89(变量 chinese) PyObject *left = TOP(); // 用于保存两者的和 PyObject *sum; // 如果是字符串,执行专门的函数 if (PyUnicode_CheckExact(left) && PyUnicode_CheckExact(right)) { sum = unicode_concatenate(tstate, left, right, f, next_instr); } // 否则通过泛型 API PyNumber_Add 进行计算 else { sum = PyNumber_Add(left, right); Py_DECREF(left); } // 从栈里面弹出,所以减少引用计数 Py_DECREF(right); // 将栈顶元素替换成 sum SET_TOP(sum); if (sum == NULL) goto error; DISPATCH(); }
BINARY_ADD 指令执行完之后,栈帧的布局就变成了下面这样:
然后 18 LOAD_NAME 负责将符号表中索引为 2 的变量 english 的值压入运行时栈;而指令 20 BINARY_ADD 则是继续执行加法操作,并将结果设置在栈顶;然后 22 LOAD_CONST 将常量 3 再压入运行时栈。
这三条指令执行之后,运行时栈变化如下:
接着是 24 BINARY_TRUE_DIVIDE,它的逻辑和 BINARY_ADD 类似,只不过一个执行除法,一个执行加法。
case TARGET(BINARY_TRUE_DIVIDE): { // 从栈顶弹出元素,显然是 3 PyObject *divisor = POP(); // 查看栈顶元素,此时栈顶元素变成了 279 PyObject *dividend = TOP(); // 调用 PyNumber_TrueDivide,执行 279 / 3 PyObject *quotient = PyNumber_TrueDivide(dividend, divisor); // 从栈里面弹出,减少引用计数 Py_DECREF(dividend); Py_DECREF(divisor); // 将栈顶元素替换为 279 / 3 的计算结果 SET_TOP(quotient); if (quotient == NULL) goto error; DISPATCH(); }
24 BINARY_TRUE_DIVIDE 执行完之后,运行时栈如下:
然后 26 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}
现在你是不是对虚拟机执行字节码有一个更深的了解了呢?当然字节码指令有很多,不止我们上面看到的那几个。你可以随便写一些代码,然后分析一下它的字节码指令是什么样的。
指令都定义在 Include/opcode.h 中
变量赋值时用到的指令
这里我们来介绍几个在变量赋值的时候,所用到的指令。因为出现频率极高,所以有必要单独说一下。
下面来实际操作一波,看看这些指令:
0 LOAD_CONST:加载字符串常量 "female";
2 STORE_FAST:在局部作用域中定义一个局部变量 gender,和字符串对象 "female" 建立映射关系,本质上就是让变量 gender 保存这个字符串对象的地址;
4 LOAD_GLOBAL:在局部作用域中加载一个内置变量 print;
6 LOAD_FAST:在局部作用域中加载一个局部变量 gender;
14 LOAD_GLOBAL:在局部作用域中加载一个全局变量 name;
0 LOAD_CONST:加载字符串常量 "古明地恋";
2 STORE_GLOBAL:在局部作用域中定义一个被 global 关键字声明的全局变量;
0 LOAD_CONST:加载字符串常量 "古明地觉";
2 STORE_NAME:在全局作用域中定义一个全局变量 name,并和上面的字符串对象进行绑定;
4 LOAD_NAME:在全局作用域中加载一个内置变量 print;
6 LOAD_NAME:在全局作用域中加载一个全局变量 name;
以上我们就通过代码实际演示了这些指令的作用,它们和常量、变量的加载,以及变量的定义密切相关,可以说常见的不能再常见了。你写的任何代码在反编译之后都少不了它们的身影,因此有必要提前解释一下。
不管加载的是常量、还是变量,得到的永远是指向对象的指针。
变量赋值的具体细节
这里再通过变量赋值感受一下字节码的执行过程,首先关于变量赋值,你平时是怎么做的呢?
这些赋值语句背后的原理是什么呢?我们通过字节码来逐一回答。
1)a, b = b, a 的背后原理是什么?
想要知道背后的原理,查看它的字节码是我们最好的选择。
0 LOAD_NAME 0 (b) 2 LOAD_NAME 1 (a) 4 ROT_TWO 6 STORE_NAME 1 (a) 8 STORE_NAME 0 (b)
里面关键的就是 ROT_TWO 指令,虽然我们还没看这个指令,但也能猜出来它负责交换栈里面的两个元素。假设 a 和 b 的值分别为 22、33,整个过程如下:
来看一下 ROT_TWO 指令。
case TARGET(ROT_TWO): { // 获取栈顶元素,由于 b 先入栈、a 后入栈 // 再加上栈是先入后出,所以这里获取的栈顶元素就是 a PyObject *top = TOP(); // 运行时栈的第二个元素就是 b // TOP 是查看栈顶元素、SECOND 是查看栈的第二个元素 // 并且这两个宏只是获取,不会将元素从栈中弹出 PyObject *second = SECOND(); // 将栈顶元素设置为 second,这里显然就是变量 b // 将栈的第二个元素设置为 top,这里显然就是变量 a SET_TOP(second); SET_SECOND(top); FAST_DISPATCH(); }
因此执行完 ROT_TWO 指令之后,栈顶元素就是 b,栈的第二个元素就是 a。然后后面的两个 STORE_NAME 会将栈里面的元素 b、a 依次弹出,赋值给 a、b,从而完成变量交换。
2)a, b, c = c, b, a 的背后原理是什么?
老规矩,还是查看字节码,因为一切真相都隐藏在字节码当中。
0 LOAD_NAME 0 (c) 2 LOAD_NAME 1 (b) 4 LOAD_NAME 0 (a) 6 ROT_THREE 8 ROT_TWO 10 STORE_NAME 2 (a) 12 STORE_NAME 1 (b) 14 STORE_NAME 0 (c)
整个过程和 a, b = b, a 是相似的,首先 LOAD_NAME 将变量 c、b、a 依次压入栈中。由于栈先入后出的特性,此时栈的三个元素按照顺序(从栈顶到栈底)分别是 a、b、c。
然后是 ROT_THREE 和 ROT_TWO,毫无疑问,这两个指令执行完之后,会将栈的三个元素调换顺序,也就是将 a、b、c 变成 c、b、a。
最后 STORE_NAME 将栈的三个元素 c、b、a 依次弹出,分别赋值给 a、b、c,从而完成变量的交换。
因此核心就在 ROT_THREE 和 ROT_TWO 上面,由于后者上面已经说过了,所以我们看一下 ROT_THREE。
case TARGET(ROT_THREE): { PyObject *top = TOP(); PyObject *second = SECOND(); PyObject *third = THIRD(); SET_TOP(second); SET_SECOND(third); SET_THIRD(top); FAST_DISPATCH(); }
栈顶元素是 top、栈的第二个元素是 second、栈的第三个元素是 third,然后将栈顶元素设置为 second、栈的第二个元素设置为 third、栈的第三个元素设置为 top。
所以栈里面的 a、b、c 在经过 ROT_THREE 之后就变成了 b、c、a,显然这还不是正确的结果。于是继续执行 ROT_TWO,将栈的前两个元素进行交换,执行完之后就变成了 c、b、a。
假设 a、b、c 的值分别为 "a"、"b"、"c",整个过程如下:
3)a, b, c, d = d, c, b, a 的背后原理是什么?它和上面提到的 1)和 2)有什么区别呢?
我们还是看一下字节码。
0 LOAD_NAME 0 (d) 2 LOAD_NAME 1 (c) 4 LOAD_NAME 2 (b) 6 LOAD_NAME 3 (a) 8 BUILD_TUPLE 4 10 UNPACK_SEQUENCE 4 12 STORE_NAME 3 (a) 14 STORE_NAME 2 (b) 16 STORE_NAME 1 (c) 18 STORE_NAME 0 (d)
依旧是将等号右边的变量,按照从左往右的顺序,依次压入栈中,但此时没有直接将栈里面的元素做交换,而是构建一个元组。因为往栈里面压入了四个元素,所以 BUILD_TUPLE 后面的 oparg 是 4,表示构建长度为 4 的元组。
case TARGET(BUILD_TUPLE): { PyObject *tup = PyTuple_New(oparg); if (tup == NULL) goto error; // 元素从栈顶到栈底依次是 a、b、c、d // 所以元素弹出也是这个顺序 // 但是注意循环,元素是从后往前设置的 // 所以 item[3], item[2], item[1], item[0] = a, b, c, d while (--oparg >= 0) { PyObject *item = POP(); PyTuple_SET_ITEM(tup, oparg, item); } // 将元组 item 压入栈中,元组为 (d, c, b, a) PUSH(tup); DISPATCH(); }
此时栈里面只有一个元素,指向一个元组。接下来是 UNPACK_SEQUENCE,负责对序列进行解包,它的指令参数也是 4,表示要解包的序列的长度为 4,我们来看看它的逻辑。
case TARGET(UNPACK_SEQUENCE): { PREDICTED(UNPACK_SEQUENCE); // seq:从栈里面弹出的元组 (d, c, b, a) // item:用于遍历元素 // items:指向一个 PyObject * 类型的数组 PyObject *seq = POP(), *item, **items; if (PyTuple_CheckExact(seq) && PyTuple_GET_SIZE(seq) == oparg) { // 获取元组内部的 ob_item 成员 // 元素就存储在它指向的数组中 items = ((PyTupleObject *)seq)->ob_item; // 遍历内部的每一个元素,并依次压入栈中 // 由于是从后往前遍历的,所以遍历的元素依次是 a b c d // 但在压入栈中之后,元素从栈顶到栈底就变成了 d c b a while (oparg--) { item = items[oparg]; Py_INCREF(item); PUSH(item); } } else if (PyList_CheckExact(seq) && PyList_GET_SIZE(seq) == oparg) { // 该指令同样适用于列表,逻辑一样 items = ((PyListObject *)seq)->ob_item; while (oparg--) { item = items[oparg]; Py_INCREF(item); PUSH(item); } } // ... Py_DECREF(seq); DISPATCH(); }
最后 STORE_NAME 将 d c b a 依次弹出,赋值给变量 a b c d,从而完成变量交换。所以当交换的变量多了之后,不会直接在运行时栈里面操作,而是将栈里面的元素挨个弹出,构建元组;然后再按照指定顺序,将元组里面的元素重新压到栈里面。
假设变量 a b c d 的值分别为 1 2 3 4,我们画图来描述一下整个过程。
不管是哪一种做法,Python在进行变量交换时所做的事情是不变的,核心分为三步走。首先将等号右边的变量,按照从左往右的顺序,依次压入栈中;然后对运行时栈里面元素的顺序进行调整;最后再将运行时栈里面的元素挨个弹出,还是按照从左往右的顺序,再依次赋值给等号左边的变量。
只不过当变量不多时,调整元素位置会直接基于栈进行操作;而当达到四个时,则需要额外借助于元组。
然后多元赋值也是同理,比如 a, b, c = 1, 2, 3,看一下它的字节码。
0 LOAD_CONST 0 ((1, 2, 3)) 2 UNPACK_SEQUENCE 3 4 STORE_NAME 0 (a) 6 STORE_NAME 1 (b) 8 STORE_NAME 2 (c)
元组直接作为一个常量被加载进来了,然后解包,再依次赋值。
4)a, b, c, d = d, c, b, a 和 a, b, c, d = [d, c, b, a] 有区别吗?
答案是没有区别,两者在反编译之后对应的字节码指令只有一处不同。
0 LOAD_NAME 0 (d) 2 LOAD_NAME 1 (c) 4 LOAD_NAME 2 (b) 6 LOAD_NAME 3 (a) 8 BUILD_LIST 4 10 UNPACK_SEQUENCE 4 12 STORE_NAME 3 (a) 14 STORE_NAME 2 (b) 16 STORE_NAME 1 (c) 18 STORE_NAME 0 (d)
前者是 BUILD_TUPLE,现在变成了 BUILD_LIST,其它部分一模一样,所以两者的效果是相同的。当然啦,由于元组的构建比列表快一些,因此还是推荐第一种写法。
5)a = b = c = 123 背后的原理是什么?
如果变量 a、b、c 指向的值相同,比如都是 123,那么便可以通过这种方式进行链式赋值。那么它背后是怎么做的呢?
0 LOAD_CONST 0 (123) 2 DUP_TOP 4 STORE_NAME 0 (a) 6 DUP_TOP 8 STORE_NAME 1 (b) 10 STORE_NAME 2 (c)
出现了一个新的字节码指令 DUP_TOP,只要搞清楚它的作用,事情就简单了。
case TARGET(DUP_TOP): { // 获取栈顶元素,注意是获取、不是弹出 // TOP:查看元素,POP:弹出元素 PyObject *top = TOP(); // 增加指向对象的引用计数 Py_INCREF(top); // 压入栈中 PUSH(top); FAST_DISPATCH(); }
所以 DUP_TOP 干的事情就是将栈顶元素拷贝一份,再重新压到栈里面。另外不管链式赋值语句中有多少个变量,模式都是一样的.
我们以 a = b = c = d = e = 123 为例:
0 LOAD_CONST 0 (123) 2 DUP_TOP 4 STORE_NAME 0 (a) 6 DUP_TOP 8 STORE_NAME 1 (b) 10 DUP_TOP 12 STORE_NAME 2 (c) 14 DUP_TOP 16 STORE_NAME 3 (d) 18 STORE_NAME 4 (e)
将常量压入运行时栈,然后拷贝一份,赋值给 a;再拷贝一份,赋值给 b;再拷贝一份,赋值给 c;再拷贝一份,赋值给 d;最后自身赋值给 e。
以上就是链式赋值的秘密,其实没有什么好神奇的,就是将栈顶元素进行拷贝,再依次赋值。
但是这背后有一个坑,就是给变量赋的值不能是可变对象,否则容易造成 BUG。
a = b = c = {} a["ping"] = "pong" print(a) # {'ping': 'pong'} print(b) # {'ping': 'pong'} print(c) # {'ping': 'pong'}
虽然 Python 一切皆对象,但对象都是通过指针来间接操作的。所以 DUP_TOP 是将字典的地址拷贝一份,而字典只有一个,因此最终 a、b、c 会指向同一个字典。
6)a is b 和 a == b 的区别是什么?
is 用于判断两个变量是不是引用同一个对象,也就是保存的对象的地址是否相等;而 == 则是判断两个变量引用的对象是否相等,等价于 a.__eq__(b) 。
Python 的变量在 C 看来只是一个指针,因此两个变量是否指向同一个对象,等价于 C 中的两个指针存储的地址是否相等;
而 Python 的 ==,则需要调用 PyObject_RichCompare,来比较它们指向的对象所维护的值是否相等。
这两个语句的字节码指令是一样的,唯一的区别就是指令 COMPARE_OP 的参数不同。
// a is b 0 LOAD_NAME 0 (a) 2 LOAD_NAME 1 (b) 4 COMPARE_OP 8 (is) 6 POP_TOP // a == b 0 LOAD_NAME 0 (a) 2 LOAD_NAME 1 (b) 4 COMPARE_OP 2 (==) 6 POP_TOP
我们看到指令参数一个是 8、一个是 2,然后是 COMPARE_OP 指令的背后逻辑:
case TARGET(COMPARE_OP): { // 弹出栈顶元素,这里是 b PyObject *right = POP(); // 显然 left 就是 a // b 被弹出之后,它成为新的栈顶 PyObject *left = TOP(); // 进行比较,比较结果为 res PyObject *res = cmp_outcome(tstate, oparg, left, right); // 减少 left 和 right 引用计数 Py_DECREF(left); Py_DECREF(right); // 将栈顶元素替换为 res SET_TOP(res); if (res == NULL) goto error; // 指令预测,暂时不用管 PREDICT(POP_JUMP_IF_FALSE); PREDICT(POP_JUMP_IF_TRUE); // 相当于 continue DISPATCH(); }
所以逻辑很简单,核心就在 cmp_outcome 函数中。
static PyObject * cmp_outcome(int op, PyObject *v, PyObject *w) { int res = 0; // op 就是 COMPARE_OP 指令的参数 switch (op) { // PyCmp_IS 是一个枚举变量,等于 8 // 定义在 Include/opcode.h 中 case PyCmp_IS: // is 操作符,在 C 的层面直接一个 == 判断即可 res = (v == w); break; // ... default: // 而 PyObject_RichCompare 是一个函数调用 // 比较对象维护的值是否相等 return PyObject_RichCompare(v, w, op); } v = res ? Py_True : Py_False; Py_INCREF(v); return v; }
我们实际举个栗子:
a = 3.14 b = float("3.14") print(a is b) # False print(a == b) # True
a 和 b 都是 3.14,两者是相等的,但不是同一个对象。
反过来也是如此,如果 a is b 成立,那么 a == b 也不一定成立。可能有人好奇,a is b 成立说明 a 和 b 指向的是同一个对象,那么 a == b 表示该对象和自己进行比较,结果应该始终是相等的呀,为啥也不一定成立呢?以下面两种情况为例:
class Girl: def __eq__(self, other): return False g = Girl() print(g is g) # True print(g == g) # False
__eq__ 返回 False,此时虽然是同一个对象,但是两者不相等。
import math import numpy as np a = float("nan") b = math.nan c = np.nan print(a is a, a == a) # True False print(b is b, b == b) # True False print(c is c, c == c) # True False
nan 是一个特殊的浮点数,意思是 not a number(不是一个数字),用于表示空值。而 nan 和所有数字的比较结果均为 False,即使是和它自身比较。
但需要注意的是,在使用 == 进行比较的时候虽然是不相等的,但如果放到容器里面就不一定了。举个例子:
import numpy as np lst = [np.nan, np.nan, np.nan] print(lst[0] == np.nan) # False print(lst[1] == np.nan) # False print(lst[2] == np.nan) # False # lst 里面的三个元素和 np.nan 均不相等 # 但是 np.nan 位于列表中,并且数量是 3 print(np.nan in lst) # True print(lst.count(np.nan)) # 3
出现以上结果的原因就在于,元素被放到了容器里,而容器的一些 API 在比较元素时会先判定它们存储的对象的地址是否相同,即:是否指向了同一个对象。如果是,直接认为相等;否则,再去比较对象维护的值是否相等。
可以理解为先进行 is 判断,如果结果为 True,直接判定两者相等;如果 is 操作的结果不为 True,再去进行 == 判断。
因此 np.nan in lst 的结果为 True,lst.count(np.nan) 的结果是 3,因为它们会先比较对象的地址。地址相同,则直接认为对象相等。
在用 pandas 做数据处理的时候,nan 是一个非常容易坑的地方。
提到 is 和 ==,那么问题来了,在和 True、False、None 比较时,是用 is 还是用 == 呢?
由于 True、False、None 它们不仅是关键字,而且也被看做是一个常量,最重要的是它们都是单例的,所以我们应该用 is 判断。
另外 is 在底层只需要一个 == 即可完成;但 Python 的 ==,在底层则需要调用 PyObject_RichCompare 函数。因此 is 在速度上也更有优势,== 操作肯定比函数调用要快。
小结
以上我们就研究了虚拟机是如何执行字节码的,相信你对 Python 虚拟机也有了更深的了解。说白了虚拟机就是把自己当成一颗 CPU,在栈帧中不停地执行字节码指令。
而执行逻辑就是 _PyEval_EvalFrameDefault 里面的那个大大的 for 循环,for 循环里面有一个巨型 switch,case 了所有的分支,不同的分支执行不同的逻辑。