初看 CPython

2.5k words

什么是 CPython

简单的说, CPython 是 Python 解释器的一个实现。换句话说,Python 解释器是由 C 写的,然后由 GCC 之类的编译器编译而成的可执行文件。除了 CPython,还有 PyPy(用 Python 实现的 Python 解释器),Skulpt(用 JavaScript 实现的 Python 解释器) 等等。如果你不知道你用的是哪一个,那么你一定用的就是 CPython。因为 CPython 是 Python 官方的解释器实现,其它均属于第三方实现。

从源码到运行

对于从 Python 源码到解释器执行它们的这个过程,我把它分为两大步 —— 编译(Compiling) 与 解释(Interpreting)。

编译

尽管 Python 一门典型的解释型语言 —— 与编译型语言(C, C++) 相对,但 Python 的运行确实涉及到了编译的部分。

编译在这里的主要功能是将源代码转换为字节码,包括编译了原理中两个关键步骤,词法分析与语法分析,即 lexing, parsing, 也包含了语法检查,即 SyntaxError 可能在这个过程中抛出。

而由 Python 编译而来的字节码似于如下:

1
2
3
4
5
6
7
1           0 LOAD_NAME                0 (print)
2 LOAD_CONST 0 ('hello world')
4 CALL_FUNCTION 1
6 POP_TOP
8 LOAD_CONST 1 (None)
10 RETURN_VALUE

关于字节码的内容,后面将进一步讨论

解释

在这一步,Python 解释器对编译生成的字节码进行解释。且在实际过程中,编译所做的只占很少一部分,也就是说:解释的部分远大于编译的部分 —— 这也是为什么 Python 仍被成为解释型语言的重要原因之一。

因为 Python 解释器对字节码而非源码进行解释,因此 Python 解释器也会被称为 Python 虚拟机(Python Virtual Machine / PVM)。特别需要指出,尽管 Python 虚拟机与 Java 虚拟机都被称为虚拟机,但两者内部之间仍有较大差别(可以参考link)。

字节码的意义

为什么不直接解释源码?
实际上,直接解释源码在理论上当然是可行的。但这样做也有一些缺点。
举个例子:

1
2
3
4
5
6
def test(x, y):
if x < y:
return 1
elif x == y:
return 2
return 3

如果直接解释源码,那么你每次执行 test 函数都要对函数体重新分析,也就要依次分析出 if 语块,if 条件部分。等结构分析好了,之后,才能对 x, y 取值进行比较。

但事实上,代码一旦写好,结构不会再变,会变的只是变量的取值。那么可以先对其编译,编译好后再解释就不需要每次重新分析结构,而是可以直接就对 x, y 进行取值比较。从这个角度讲,编译成字节码提高了解释器的效率。

其次,字节码的存在类似于汇编的存在。汇编介于 C 语言与硬件之间,作为抽象的中间层用于降低开发的复杂度。Python 中的字节码也是如此。

字节码指令集

查看字节码

Python 提高 dis 模块供用户查看由 Python 源码编译而成的字节码。

假设下列代码是 test.py 中的全部内容:

1
2
3
x = 1
y = 2
z = x + y

在终端中输入

1
python -m dis test.py

可以看到输出的字节码:

1
2
3
4
5
6
7
8
9
10
11
12
1           0 LOAD_CONST               0 (1)
3 STORE_NAME 0 (x)

2 6 LOAD_CONST 1 (2)
9 STORE_NAME 1 (y)

3 12 LOAD_NAME 0 (x)
15 LOAD_NAME 1 (y)
18 BINARY_ADD
19 STORE_NAME 2 (z)
22 LOAD_CONST 2 (None)
25 RETURN_VALUE

其中每一列代表的含义为:

1
2
行号            字节码偏移量     字节码指令        指令参数          对于参数的相关说明
1 0 LOAD_CONST 0 (1)

解释字节码

Python 解释器对字节码指令进行解释,同时对 (Stack)进行操作 —- Python 虚拟机属于 栈机器 (Stack machine)。值的存取都是基于栈来实现的。类似下图:
7 + 20 的实现过程: 将读到的7 与 20存放与栈中,然后取出相加, 并将结果放入栈中7 + 20 的实现过程: 将读到的7 与 20存放与栈中,然后取出相加, 并将结果放入栈中

栈机器 优于 寄存器机器(Register Virtual Machine) 的一个地方是不需要对地址的存取,数据的读取通过 POP 和 PUSH 的到,而非通过一个寄存器地址,操作上相对简单。

字节码指令集有哪些

所有的指令码可以在这个网页中看到: https://hg.python.org/cpython/file/v2.7.8/Include/opcode.h。这里以网上资料比较多的 2.7.8 为例。从 0 – 147 共148个指令,每个指令都对应特定的功能。任何 Python 源码编译后形成的字节码都可以在这其中找到。

字节码从哪里被执行

仍以 2.7.8 为例,查看 CPython 工程的 Python/ceval.c 文件: https://hg.python.org/cpython/file/v2.7.8/Python/ceval.c
第 964 行处有一个 for (;;) 语句块,负责不断读入每一条指令并执行。

继续往下看,第 1112 行有一个“庞大”的 switch 语块。负责检查每一条指令具体是哪一条指令,然后采取对应的操作。

以 1148 行的 POP_TOP 为例:

1
2
3
4
case POP_TOP:
v = POP();
Py_DECREF(v);
goto fast_next_opcode;

对应的操作可描述为: 取并弹出栈顶的数据,对这个数据的计数器减一,执行下一条指令。

CPython 便是以此循环,直到因为用户终止等原因才停止运行。

后续

目前这是一篇极其浅显的对 CPython 的描述。我最近正在学习和研究 CPython 的源码。如果有新的理解,我会继续更新。

参考

  1. Allison Kaptur - Bytes in the Machine: Inside the CPython interpreter - PyCon 2015
  2. CPython internals - Interpreter and source code overview
  3. Stack based vs Register based Virtual Machine Architecture, and the Dalvik VM
  4. Java “Virtual Machine” vs. Python “Interpreter” parlance?