
Python基本原理:代码执行过程
本文以Python官方文档和CPython实现作为参考,示例代码均在3.13.7测试通过。
Python解释器
众所周知,Python是一个门解释型的语言。但是Python实际执行代码的过程并不像许多网络资料所说的“在运行时逐行解释执行源代码”那样简单,而是和Java一样,先将源代码编译成字节码,然后由虚拟机逐条执行这些字节码。
把Python编译器和Python虚拟机结合起来,就是Python解释器。Python解释器本身就是一个程序,如果你已经配好了Python环境,那么在Linux平台上使用which python
或者在Windows Powershell中使用gcm python
就可以看到这个程序对应的可执行文件。我们平时python main.py
执行代码的意思就是告诉操作系统:启动Python解释器这个程序,并且把main.py作为这个程序的参数。
解释器程序运行起来之后,它会先检测接收到的参数,然后在当前工作目录(Current Working Directory,CWD)寻找叫做main.py的文件,如果当前工作目录下没有这个文件,就会抛出No such file or directory之类的错误。
解释器找到文件之后,就会读取文件的内容并调用编译器把其编译成一个代码对象(代码对象中包含字节码)。如果文件内容有语法错误导导致无法正确编译,就会抛出SyntaxError错误。
在源代码被正确编译为代码对象之后,Python解释器就会调用虚拟机运行这个代码对象。
整个过程如下图:

提示
如果某个模块被是被import进来的,那么它对应的源代码不仅会被编译,还会把编译结果保存为一个.pyc文件,只要源代码没有改动,直接读取.pyc的内容就可以了,省去了从头编译的开销。
注意:顶层模块main.py不是被import进来的,所以不会生成.pyc文件的。
.pyc文件保存在
__pycahe__
目录下。
直接使用python
不加任何参数从而进入一个交互式环境的时候,执行逻辑和这个是一致的,只不过源代码不是一次性输入的而已。每输入一段代码,同样会经历编译和执行两个阶段。
编译与反编译
在Python中编译一段Python代码非常简单,使用内置的compile
函数既可。这个函数的签名如下:
1compile(
2 source,
3 filename,
4 mode,
5 flags=0,
6 dont_inherit=False,
7 optimize=-1,
8 *,
9 _feature_version=-1,
10)
我们只用关心前3个参数:
source
:源代码(字符串)或者抽象语法树对象(Abstract Syntax Tree,AST)。AST是源代码经过词法分析和语法分析之后的产物。filename
:源代码所属的文件名,主要用于错误信息提示。mode
:编译模式。可选值有3个:exec
:模块模式。一次性编译整个源文件的时候选择这个。single
:用于单条交互语句。前面提到的交互式环境中就是用的这个模式。eval
:用于单个表达式。例如使用eval函数动态执行代码的时候:eval(a + b)
。
1code = compile("print('Hello World!')", "main.py", "exec")
2
3print(code.co_filename)
4print(code.co_name)
5print(type(code.co_code), code.co_code)
上面代码的输出结果:
1main.py
2<module>
3<class 'bytes'> b'\x95\x00\\\x00"\x00S\x005\x01\x00\x00\x00\x00\x00\x00 \x00g\x01'
其中.co_code
就是对应的字节码,是一个二进制数据(相当于机器代码),所以需要借助反编译模块找到对应的助记符(相当于汇编代码)。
1import dis
2
3code = compile("print('Hello World!')", "main.py", "exec")
4
5dis.dis(code.co_code)
结果如下(dis.dis
自带输出):
1 RESUME 0
2 LOAD_NAME 0
3 PUSH_NULL
4 LOAD_CONST 0
5 CALL 1
6 POP_TOP
7 RETURN_CONST 1
好看多了。但是这就相当于纯机器指令部分,没有符号表,所以不知道0和1这些下标代表的什么。我们可以直接反编译整个代码对象:
1import dis
2
3code = compile("print('Hello World!')", "main.py", "exec")
4
5dis.dis(code)
6
7print(code.co_names)
8print(code.co_consts)
结果:
1 0 RESUME 0
2
3 1 LOAD_NAME 0 (print)
4 PUSH_NULL
5 LOAD_CONST 0 ('Hello World!')
6 CALL 1
7 POP_TOP
8 RETURN_CONST 1 (None)
9('print',)
10('Hello World!', None)
这下更清晰了,基本可以当自然语言看了。
提示
你甚至可以直接dis一个源代码字符串,dis会自动调用compile然后再反编译,效果和dis代码对象一样。
Python虚拟机
在冯诺依曼架构计算中,二进制指令按照顺序放在内存中,CPU依次读取并执行这些指令,如果中间遇到跳转指令,就跳转到目标指令上,然后继续顺序读取执行,直到遇到下一条跳转指令。通过跳转指令可以实现分支、循环和函数调用。
而Python虚拟机,就是一个按照这种思路设计的“软件CPU”。字节码中同样有许多跳转指令,例如前面看到的CALL字节码就是函数调用指令。
在物理机中,每当发生函数调用时,CPU都会通过栈指针在栈空间中划分出一个栈帧,供函数保存中间计算结果。Python执行函数当然也需要“栈帧”,只不过这个栈帧不是用栈指针划分的,而是直接创建一个帧对象,在帧对象的尾部有一些额外空间,可供函数保存中间计算结果。
除此之外,帧对象还模拟了CPU中的一些寄存器,其中最重要的就是程序计数器(Program Counter,PC)。在物理机中,PC用于指示下一条将要执行的指令;在帧对象中,则是.f_lasti
记录了上一条已经执行的字节码的偏移量,虚拟机根据此信息来确定下一条将要执行的字节码。
由于完全是软件实现,帧对象比物理机中的栈帧更灵活,例如可以随时停止一个帧的执行、过一会再接着执行——反正.f_lasti
和字节码都保存的好好的。
这个特性使得Python可以方便地实现生成器和协程,这两者是构建高效Python程序绕不开的话题。
不过与之相比流传更广的可能是——“我都用Python了还管什么性能啊”。