《Python编程入门》1.3 解释器实现与执行模型

本节拆开 CPython 的执行模型:源码经过词法与语法分析生成 AST,再编译成字节码,最后由虚拟机执行。我们会用 ast 打印语法树、用 dis 真实反汇编一段代码,看到 3.14 的新字节码指令与常量折叠。随后对比 CPython、PyPy、实验性 JIT 与自由线程构建的关系,说明「Python 是解释型语言」并不准确。读完本节,你能用 dis 观察任何一段代码在解释器眼里的样子。

本节目标:搞清楚 Python 从源码到执行经历了哪些阶段,并能亲手用 ast 与 dis 观察这些阶段。
适用版本:Python 3.12+(实测 3.14.6)

1.3 解释器实现与执行模型

上一节结尾埋了一个问题:Python 说自己「解释型」,但它真的是一行一行边读边执行吗?答案是否定的。这一节我们把 Python 执行一段代码的全过程拆开看——你会亲手看到编译产物,也会发现「解释型 vs 编译型」这个二分法有多粗糙。

1.3.1 「Python 是解释型语言」哪里不准

严格来说,Python 走的是先编译、后解释的路线:

  1. 把源码编译成一种中间表示——字节码(bytecode);
  2. 由一个虚拟机循环执行这些字节码。

这跟「读一行、执行一行」的朴素想象完全不同。字节码是一种比源码低级、但比机器码高级的指令集,它不依赖具体 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 与虚拟环境 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时