本节目标:搞清楚 Python 从源码到执行经历了哪些阶段,并能亲手用
ast与dis观察这些阶段。
适用版本:Python 3.12+(实测 3.14.6)
1.3 解释器实现与执行模型
上一节结尾埋了一个问题:Python 说自己「解释型」,但它真的是一行一行边读边执行吗?答案是否定的。这一节我们把 Python 执行一段代码的全过程拆开看——你会亲手看到编译产物,也会发现「解释型 vs 编译型」这个二分法有多粗糙。
1.3.1 「Python 是解释型语言」哪里不准
严格来说,Python 走的是先编译、后解释的路线:
- 把源码编译成一种中间表示——字节码(bytecode);
- 由一个虚拟机循环执行这些字节码。
这跟「读一行、执行一行」的朴素想象完全不同。字节码是一种比源码低级、但比机器码高级的指令集,它不依赖具体 CPU,只依赖 Python 虚拟机。
所以更准确的说法是:Python 是一门先编译成字节码、再由虚拟机解释执行的语言。 它既不是纯解释(因为有编译步骤),也不是传统编译(因为不直接产出机器码)。
1.3.2 五步流水线
从源码到运行,中间有五个阶段:
| 阶段 | 做什么 | 你能用什么观察 |
|---|---|---|
| 词法分析 | 把字符流切成 token(标识符、数字、运算符……) | tokenize 模块 |
| 语法分析 | 按语法规则把 token 组织成语法树 | ast 模块 |
| 编译 | 把语法树转成字节码 | dis 模块 |
| 缓存 | 把字节码存进 .pyc,下次直接加载 | __pycache__/ 目录 |
| 执行 | 虚拟机(PVM)循环执行字节码 | dis + 调试器 |
这五步里,前三步发生在「导入或运行一个 .py 文件」时;缓存步骤只在导入模块时发生(直接 python3 script.py 运行主脚本不会写 .pyc);执行步骤贯穿程序整个生命周期。
1.3.3 词法与语法分析:tokenize 与 ast
第一步词法分析把字符流切成 token。tokenize 模块能让你看到切分结果:
import tokenize, io
src = 'x = 1 + 2\n'
for tok in tokenize.generate_tokens(io.StringIO(src).readline):
print(tokenize.tok_name[tok.type], repr(tok.string))
真实输出:
NAME 'x'
OP '='
NUMBER '1'
OP '+'
NUMBER '2'
NEWLINE '\n'
ENDMARKER ''
每个 token 带一个类型和一个值:NAME 是标识符,OP 是运算符,NUMBER 是数字,ENDMARKER 标记输入结束。注意这一步还完全不知道「这是个赋值语句」——它只负责切分。
第二步语法分析,则把这些扁平的 token 组织成有层次的结构。ast 模块能让你直接看到这一步的产物。执行下面这段代码:
import ast
tree = ast.parse("x = 1 + 2")
print(ast.dump(tree, indent=2))
真实输出(Python 3.14.6):
Module(
body=[
Assign(
targets=[
Name(id='x', ctx=Store())],
value=BinOp(
left=Constant(value=1),
op=Add(),
right=Constant(value=2)))])
这棵树的含义是:一个 Module,里面有一个 Assign(赋值),左边是「存储模式」的 Name('x'),右边是一个 BinOp(二元运算),运算对象是常量 1 和 2,操作符是 Add。注意 1 + 2 到这一步还没有被算成 3——语法树忠实保留了你写的结构。
1.3.4 编译:用 dis 看字节码
下一步,把语法树编译成字节码。dis 模块(disassembler,反汇编器)能把它打印出来:
import dis
def add(a, b):
return a + b
def classify(n):
if n > 0:
return "positive"
return "non-positive"
dis.dis(add)
print("=" * 40)
dis.dis(classify)
真实输出:
3 RESUME 0
4 LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (a, b)
BINARY_OP 0 (+)
RETURN_VALUE
========================================
6 RESUME 0
7 LOAD_FAST_BORROW 0 (n)
LOAD_SMALL_INT 0
COMPARE_OP 148 (bool(>))
POP_JUMP_IF_FALSE 3 (to L1)
NOT_TAKEN
8 LOAD_CONST 1 ('positive')
RETURN_VALUE
9 L1: LOAD_CONST 2 ('non-positive')
RETURN_VALUE
左边一列是源码行号,中间是指令名,右边是参数。读几行就明白 PVM 在做什么:
RESUME:函数入口,通知虚拟机从这里开始。LOAD_FAST_BORROW:把一个局部变量压到栈上(BORROW表示借引用、不增加计数,是 3.14 的优化)。BINARY_OP:弹两个值做运算,把结果压回栈。COMPARE_OP+POP_JUMP_IF_FALSE:比较n > 0,为假就跳转。RETURN_VALUE:弹出栈顶作为返回值。
一个有意思的细节:classify 里的 n > 0,编译器把 0 编译成了 LOAD_SMALL_INT——这是 3.14 为小整数引入的专用指令。而在更早的版本里,你看到的会是 LOAD_CONST。同一段源码,不同 Python 版本会生成不同的字节码,这就是为什么 .pyc 文件带版本标记,不能跨版本复用。
1.3.5 编译器不是照单全收:常量折叠
编译器还会做优化。把 1 + 2 编译出来看看:
import dis
code = compile("x = 1 + 2", "<demo>", "exec")
print("co_code (raw):", code.co_code)
dis.dis(code)
真实输出:
co_code (raw): b'\x80\x00^\x03t\x00R\x01#\x00'
0 RESUME 0
1 LOAD_SMALL_INT 3
STORE_NAME 0 (x)
LOAD_CONST 1 (None)
RETURN_VALUE
注意:字节码里根本没有 1 + 2 的运算,只有一条 LOAD_SMALL_INT 3。编译器在编译期就把常量表达式算完了,这叫常量折叠(constant folding)。co_code 那串 bytes 就是字节码的原始形态——你平时看 dis 的友好输出,是它被解码后的样子。
这也解释了为什么「用 Python 写 2 ** 10000000 这种常量表达式」不会在运行时卡住——它在编译期就已经算完了。
1.3.6 再看一例:循环的字节码
标量运算看不出循环控制,换一段带 for 的代码:
import dis
source = """
total = 0
for i in range(3):
total += i
print(total)
"""
dis.dis(compile(source, "<demo>", "exec"))
真实输出(节选):
3 LOAD_NAME 1 (range)
PUSH_NULL
LOAD_SMALL_INT 3
CALL 1
GET_ITER
L1: FOR_ITER 12 (to L2)
STORE_NAME 2 (i)
4 LOAD_NAME 0 (total)
LOAD_NAME 2 (i)
BINARY_OP 13 (+=)
STORE_NAME 0 (total)
JUMP_BACKWARD 14 (to L1)
3 L2: END_FOR
POP_ITER
关键在 FOR_ITER 与 JUMP_BACKWARD 这一对:FOR_ITER 从迭代器取下一个值,取不到就跳到 L2 退出循环;取到了就执行循环体,循环体末尾的 JUMP_BACKWARD 再跳回 L1。所谓「循环」,在字节码层面就是一个向后的跳转。
这也解释了为什么 for 循环能遍历任何实现了迭代器协议的对象——字节码只调用迭代器的 __next__,根本不关心你遍历的是列表、字典还是文件。第 8 章会把这套迭代器协议讲透。
1.3.7 字节码缓存:.pyc 文件
当你 import 一个模块时,CPython 会把编译好的字节码存进 __pycache__/ 目录:
__pycache__/mymodule.cpython-314.pyc
文件名里的 cpython-314 表示「CPython 3.14 生成」。下次再导入同一模块,只要源码没变,就直接加载 .pyc,跳过词法与语法分析。这就是「编译」这一步实际发生过的证据。
用 compileall 可以手动为整个目录生成 .pyc:
python3 -m compileall mypackage/
1.3.8 CPython、PyPy、JIT 与自由线程
「Python」其实是一门语言规范,而 CPython 是它最主流的实现。除了 CPython,还有别的实现:
| 实现 | 特点 | 适用场景 |
|---|---|---|
| CPython | 官方实现,C 写成,生态最全 | 绝大多数场景 |
| PyPy | 带 JIT 的实现,长时间运行的纯 Python 代码更快 | CPU 密集、长驻进程 |
| GraalPy | 基于 JVM 的实现 | 与 Java 生态互操作 |
| MicroPython | 精简实现 | 单片机、嵌入式 |
值得单独说的是 JIT(Just-In-Time 编译)。它的思路是:运行时观察哪些字节码反复执行,把这些「热路径」直接编译成机器码,从而绕开逐条解释的开销。
Python 3.13 起,CPython 内置了实验性 JIT——注意「实验性」三个字,它默认关闭,且不保证性能收益,现阶段只是为未来铺路。PyPy 的 JIT 则早已成熟,但它对 C 扩展的支持有限,这是它没能取代 CPython 的主因。
另一个 3.13 引入的实验性方向是自由线程构建(free-threaded build,PEP 703 )。传统 CPython 有一个全局解释器锁(GIL),同一时刻只允许一个线程执行字节码,这让多线程在 CPU 密集型任务上几乎无用。自由线程构建尝试移除 GIL,代价是单线程性能下降、且 C 扩展需要适配。它同样是实验性的,还不能用于生产。
GIL 到底如何影响你的并发代码,本书第 12 章会展开;想先睹为快可以看专题 Python 的 GIL 对并发编程有哪些影响 。
1.3.9 这套模型对你有什么用
你可能会想:知道字节码有什么用?用处至少有三个:
- 理解报错:
SyntaxError来自语法分析阶段,NameError/TypeError来自执行阶段。看到异常名就知道问题出在流水线的哪一环。 - 理解性能:知道循环里的字节码会被反复执行,就能理解「把计算挪到循环外」为什么有效。
- 调试利器:
dis能让你看到「你以为的代码」和「实际执行的指令」之间的差距,比如常量折叠、短路求值。
第 18.2 节讲性能剖析时,我们会重新回到 dis,用它定位真正的热点。
1.3.10 学完本节你应该能回答的问题
- 为什么说「Python 是解释型语言」不准确?(1.3.1)
- 从源码到执行有哪五个阶段,各用什么工具观察?(1.3.2)
dis.dis()输出里LOAD_FAST_BORROW、BINARY_OP在做什么?(1.3.4)- 为什么字节码里看不到
1 + 2?(1.3.5) - JIT 和自由线程构建是稳定特性还是实验特性?(1.3.8)
小结
- Python 不是纯解释,而是「先编译成字节码、再由虚拟机执行」。
- 完整流水线是:词法分析 → 语法分析(
ast)→ 编译(dis)→ 缓存(.pyc)→ 执行(PVM)。 - 编译器会做常量折叠等优化,字节码随 Python 版本变化,
.pyc不能跨版本复用。 - CPython 是主流实现;PyPy 有成熟的 JIT;CPython 的 JIT 与自由线程构建(3.13 起)都还是实验性的。
- 学会用
ast和dis观察代码,是理解报错、性能与调试的基础技能。
到这里,第 1 章就讲完了:你知道了 Python 从哪来、经历了什么、又如何在机器上跑起来。下一章我们不再谈历史,直接动手——安装 Python、配置虚拟环境,写出你的第一个脚本。
阅读导航:上一节:1.2 Python 2 到 3、发布节奏与版本选择 · 下一节:2.1 安装 Python 与虚拟环境 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。