cover

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了还管什么性能啊”。