python源码是如何运行起来的
为什么要了解底层原理
-
写出高质量代码
-
问题定位
-
满足好奇心
-
机械通感
开始
当我们编写并运行一行 print('Hello, World!')
时,背后究竟发生了什么?Python 代码是如何从我们可读的文本,变成计算机可以执行的指令的呢?
很多人将 Python 称为一门“解释型语言”,但这其实不完全准确。更精确地说,Python 是一门先编译后解释的语言,这点与java其实非常相似。
它的运行过程涉及两个核心阶段:
-
编译阶段:Python 解释器首先将我们编写的源代码(
.py
文件)编译成一种称为字节码 (Bytecode) 的中间形式。 -
解释阶段:编译产生的字节码随后由 Python 虚拟机 (Python Virtual Machine, PVM) 来解释执行。
Part.1 从 Python 源代码到字节码
Python 代码的生命周期始于我们编写的 .py
文件。当我们执行一个 Python 脚本时,解释器并不直接逐行解析这些文本。相反,它会先进行一个“预处理”步骤——编译成字节码。
什么是字节码?
字节码是一种低级、与平台无关的指令集。它不像机器码那样是给物理 CPU 执行的,而是专门为 Python 虚拟机 (PVM) 设计的。这个设计使得 Python 代码拥有了出色的可移植性——同一份 Python 代码可以在任何安装了 Python 解释器的操作系统上运行,因为字节码是标准化的。
这个过程主要分为两个核心步骤:词法分析 (Lexical Analysis) 和 语法分析 (Parsing)。可以将它类比为我们阅读句子的过程:首先识别出每个单词(词法分析),然后理解整个句子的语法结构(语法分析)。
核心流程:
源代码 -> [词法分析器] -> 令牌流 -> [语法分析器] -> 抽象语法树 (AST)->字节码
第 1 步:词法分析 (Lexical Analysis) - 将代码分解为“token”
当 Python 解释器拿到您的源代码(一串纯文本字符)时,它做的第一件事不是去理解代码的逻辑,而是先将其打碎成一个个有意义的最小单元。这些单元被称为 “令牌” (Token)。
一个令牌通常包含两部分信息:
类型:例如 NAME (名称/标识符), NUMBER (数字), OP (操作符), KEYWORD (关键字)。
值:这个令牌对应的具体文本,例如 result, 10, +。
举个例子,对于我们之前用的代码 result = a + b,词法分析器会扫描这段文本,并生成类似下面这样的一个“令牌流”:
NAME: 'result'
OP: '='
NAME: 'a'
OP: '+'
NAME: 'b'
您可以把这个阶段看作是给代码“断词”。Python 内置的 tokenize 模块可以让我们亲眼看到这个过程:
import tokenize
from io import BytesIOcode = """
result=a+b
"""tokens = tokenize.tokenize(BytesIO(code.encode('utf-8')).readline)for token in tokens:print(token)
结果
TokenInfo(type=63 (ENCODING), string='utf-8', start=(0, 0), end=(0, 0), line='')
TokenInfo(type=62 (NL), string='\n', start=(1, 0), end=(1, 1), line='\n')
TokenInfo(type=1 (NAME), string='result', start=(2, 0), end=(2, 6), line='result=a+b\n')
TokenInfo(type=54 (OP), string='=', start=(2, 6), end=(2, 7), line='result=a+b\n')
TokenInfo(type=1 (NAME), string='a', start=(2, 7), end=(2, 8), line='result=a+b\n')
TokenInfo(type=54 (OP), string='+', start=(2, 8), end=(2, 9), line='result=a+b\n')
TokenInfo(type=1 (NAME), string='b', start=(2, 9), end=(2, 10), line='result=a+b\n')
TokenInfo(type=4 (NEWLINE), string='\n', start=(2, 10), end=(2, 11), line='result=a+b\n')
TokenInfo(type=0 (ENDMARKER), string='', start=(3, 0), end=(3, 0), line='')
类型名 | 描述 |
| 变量名、关键字等 |
| 数值 |
| 字符串 |
| 运算符 |
| 缩进 |
| 取消缩进 |
| 语句结束 |
| 文件编码(如 utf-8) |
| 注释 |
| 非逻辑新行 |
| 文件结束标记 |
第2步:语法分析
现在我们有了一个扁平的、线性的令牌列表,但解释器还不知道它们之间的关系。这时 解析器 (Parser) 就登场了。
解析器的任务是接收词法分析器生成的令牌流,并根据 Python 语言预设的语法规则 (Grammar),将这些令牌组织成一个具有层级结构的树。这个树就是抽象语法树 (AST)。
继续我们的例子 result = a + b:
解析器拿到 NAME, OP('='), NAME, OP('+'), NAME 这个令牌序列。
它根据 Python 的语法规则,识别出这是一个“赋值语句”的模式。
于是它创建一个 Assign (赋值) 节点作为根。
语法规定,= 左边的是赋值目标 (target),于是它把第一个 NAME('result') 令牌作为 Assign 节点的 targets 子节点。
= 右边的 a + b 是要赋的值 (value)。解析器接着分析 NAME('a'), OP('+'), NAME('b') 这部分。
它识别出这是一个“二元运算” (Binary Operation) 的模式。
于是它创建一个 BinOp 节点,并将其作为 Assign 节点的 value 子节点。
语法规定,+ 是操作符 (op),a 是左操作数 (left),b 是右操作数 (right)。解析器相应地创建 Add、Name('a') 和 Name('b') 节点,并将它们作为 BinOp 节点的子节点。
经过这一系列操作,一个扁平的令牌流就变成了一个能清晰反映代码逻辑和结构的树状数据结构——AST。这个 AST 完整地表达了“将变量 a 和 b 相加的结果,赋值给变量 result”这一操作,并且已经去除了所有不影响语法的细节(如空格)。 将 AST(抽象语法树)转化为字节码的过程,是由 Python 的编译器 (Compiler) 完成的。这个过程可以被理解为一个**“翻译”**工作:编译器读取结构化的 AST,并将其翻译成一个线性的、供 Python 虚拟机 (PVM) 执行的指令列表。
这个“翻译”过程的核心思想是 “树的遍历” (Tree Traversal)。
第3步:遍历 AST 并生成指令
编译器会从 AST 的根节点开始,以深度优先的方式递归地访问(或称“遍历”)树中的每一个节点。对于每一种类型的节点(如 Assign
, BinOp
, Name
等),编译器都有一个专门的处理函数。这个设计模式通常被称为 访问者模式 (Visitor Pattern)。
让我们以我们熟悉的 result = a + b
为例,看看编译器是如何“走”过它的 AST 并生成字节码的。
import ast# 我们要分析的简单代码行
code_line = """
# 这是一个我们编写的简单函数
result = a + b
"""# 解析代码并获取 AST
tree = ast.parse(code_line)# ast.dump() 可以用一种开发者友好的方式打印出树的结构
print(ast.dump(tree, indent=4))
这是它的 AST 结构:
Assign(targets=[Name(id='result')],value=BinOp(left=Name(id='a'),op=Add(),right=Name(id='b'))
)
编译器的工作流程如下:
-
访问
Assign
(赋值) 节点-
编译器的规则是:要完成一次赋值,必须先计算出右边的值。
-
因此,它不会立即生成赋值指令,而是递归地去访问
Assign
节点的value
子节点,也就是BinOp
节点。
-
-
访问
BinOp
(二元运算) 节点-
编译器的规则是:要计算一个二元运算,必须先得到左右两个操作数的值。
-
它首先递归地访问
left
子节点,也就是Name(id='a')
。
-
-
访问
Name(id='a')
节点-
编译器看到这是一个变量名,并且当前上下文是需要“加载”它的值。
-
于是,它生成第一条字节码指令:
LOAD_FAST a
。这条指令的作用是:在程序运行时,从本地变量中找到a
的值,并将其推到 PVM 的操作数堆栈顶部。 -
a
节点处理完毕,返回到BinOp
节点的访问流程。
-
-
回到
BinOp
节点-
左边已经处理完。现在,编译器递归地访问
right
子节点,也就是Name(id='b')
。
-
-
访问
Name(id='b')
节点-
和处理
a
一样,编译器生成第二条字节码指令:LOAD_FAST b
。运行时,b
的值会被推到堆栈顶部。 -
b
节点处理完毕,返回到BinOp
节点的访问流程。
-
-
再次回到
BinOp
节点-
现在,左右两个子节点都处理完了。编译器知道,在运行时,
a
和b
的值会依次位于操作数堆栈上。 -
它查看操作符
op
是Add()
。 -
于是,它生成第三条字节码指令:
BINARY_ADD
。这条指令会从堆栈弹出顶部的两个值,将它们相加,然后把结果再推回堆栈顶部。 -
BinOp
节点处理完毕,返回到Assign
节点的访问流程。
-
-
再次回到
Assign
节点-
现在,
value
子节点(即a + b
)已经完全处理完。编译器知道,在运行时,a + b
的计算结果正位于操作数堆栈的顶部。 -
它查看赋值目标
targets
是Name(id='result')
。 -
于是,它生成第四条字节码指令:
STORE_FAST result
。这条指令会从堆栈顶部弹出那个计算结果,并将其存入名为result
的本地变量中。 -
Assign
节点处理完毕。
-
至此,result = a + b
这行代码的 AST 就被完整地遍历并转换成了字节码序列:
LOAD_FAST a
LOAD_FAST b
BINARY_ADD
STORE_FAST result
Part.2 解释执行
当字节码准备就绪后,就轮到 Python 虚拟机 (PVM) 登场了。PVM 是 Python 的运行时引擎,是真正执行代码的地方。它是一个在您的操作系统上运行的软件程序,它模拟了一台“理想”的计算机,专门用来执行 Python 字节码。
PVM 的核心可以理解为一个巨大的 switch
循环,我们称之为评估循环 (Evaluation Loop) 或解释器循环。这个循环不断地做三件事:
-
读取下一条字节码指令。
-
根据指令的类型,执行相应的操作。
-
重复此过程,直到没有指令为止。
PVM 是一个堆栈机 (Stack-based machine)。这意味着它使用一种叫做“调用堆栈 (Call Stack)”的数据结构来管理所有的数据和操作。当一个函数被调用时,PVM 会为这个函数创建一个新的帧 (Frame),并将其推入调用堆栈的顶部。
一个帧包含了执行该函数所需的所有信息,主要包括:
-
本地变量 (Local variables): 存储函数内部定义的变量,如
a
、b
和result
。 -
操作数堆栈 (Operand Stack): 也叫评估堆栈 (Evaluation Stack),用于执行字节码指令的临时工作区。例如
BINARY_ADD
就是在这个堆栈上完成计算的。 -
指令指针 (Instruction Pointer): 指向当前正在执行的字节码指令。
-
返回地址: 指向调用该函数的代码位置,以便函数执行完毕后可以返回。
CPython解释器核心源码
https://github.com/python/cpython/blob/3.9/Python/ceval.c
case TARGET(LOAD_FAST): {
PyObject *value = GETLOCAL(oparg);
if (value == NULL) {
format_exc_check_arg(tstate, PyExc_UnboundLocalError,
UNBOUNDLOCAL_ERROR_MSG,
PyTuple_GetItem(co->co_varnames, oparg));
goto error;
}
Py_INCREF(value);
PUSH(value);
FAST_DISPATCH();
}
case TARGET(LOAD_CONST): {
PREDICTED(LOAD_CONST);
PyObject *value = GETITEM(consts, oparg);
Py_INCREF(value);
PUSH(value);
FAST_DISPATCH();
}
case TARGET(STORE_FAST): {
PREDICTED(STORE_FAST);
PyObject *value = POP();
SETLOCAL(oparg, value);
FAST_DISPATCH();
}
留个坑
GIL、JIT、GC