读 CPython 源码最容易陷入两个极端:要么从数十万行 C 代码的入口硬啃,要么只记住几个名词,却说不清字节码、栈帧、引用计数和生成器如何连成一条执行链。更有效的办法,是把源码问题变成可以运行、观察和验证的小实验。本文以 CPython 3.8 的实现视角为边界,用一组自测问题和 Python 标准库工具搭起源码阅读地图。
一段 Python 代码究竟经历了什么
先看最关键的路径:源代码被编译成代码对象,代码对象保存字节码及相关元数据;解释器创建执行帧,再由求值循环逐条处理指令。函数调用、局部变量访问、异常处理和生成器暂停,都能放回这条路径中理解。
可以先回答三个问题:
LOAD_FAST为什么通常比按名称查找全局变量直接?- 函数对象和代码对象有什么区别?
- 解释器执行一条调用指令时,参数、返回地址和执行帧分别由谁管理?
下面的脚本可直接运行。建议使用 Python 3.8,因为不同版本会调整指令名称、调用协议和解释器内部结构。
import dis
RATE = 1.2
def total(price, count):
subtotal = price * count
return subtotal * RATE
print("Python bytecode:")
dis.dis(total)
code = total.__code__
print("\ncode object details:")
print("name:", code.co_name)
print("local names:", code.co_varnames)
print("global names:", code.co_names)
print("constants:", code.co_consts)
print("stack size:", code.co_stacksize)
运行时重点观察 price、count 和 subtotal 如何出现在 co_varnames 中,而 RATE 出现在 co_names 中。前者通常通过面向局部变量槽位的指令访问,后者需要走全局名称解析。这比单独背指令表更接近源码中的真实数据流。
在 CPython 3.8 源码中继续追踪时,可以从这些位置建立索引:
Python/ceval.c:字节码求值循环与指令分派。Python/compile.c:AST 到代码对象的编译过程。Include/code.h:代码对象的数据结构。Objects/frameobject.c:帧对象相关实现。
文件布局和结构字段会随版本变化,因此阅读新版本源码时应重新确认路径,不能把 3.8 的细节当成永久接口。
引用计数不是完整的内存管理故事
CPython 最醒目的内存管理机制是引用计数:对象的强引用归零后,通常可以立即释放。但只说“CPython 用引用计数”并不完整,因为循环引用无法仅靠计数解决,解释器还需要循环垃圾回收器。
可以用下面的实验观察引用和循环对象。sys.getrefcount() 会因为函数调用时新增了一个临时引用,所以结果通常比直觉多 1。
import gc
import sys
class Node:
pass
node = Node()
print("initial observed refcount:", sys.getrefcount(node))
alias = node
print("after alias:", sys.getrefcount(node))
del alias
print("after deleting alias:", sys.getrefcount(node))
left = Node()
right = Node()
left.peer = right
right.peer = left
del left, right
print("cyclic objects collected:", gc.collect())
这个实验适合带着四个问题去读源码:
- 新增或删除一个强引用时,引用计数宏在哪些路径中出现?
- 对象计数归零后,类型的析构逻辑如何被调用?
- 哪些对象会被循环垃圾回收器跟踪?
- 对象释放与底层内存立即归还操作系统是否是一回事?
最后一个问题尤其重要。对象生命周期、Python 内存分配器和操作系统页面管理是不同层次。看到对象被销毁,不能直接推导进程常驻内存会同步下降。
帧与生成器:暂停的是执行状态
生成器不是“每次重新执行函数并跳到上次位置”。更准确的理解是:调用生成器函数会得到生成器对象;执行到 yield 时,当前指令位置、局部变量和求值状态被保留,下一次恢复时从暂停处继续。
下面的程序同时观察生成器状态、当前帧和局部变量:
import inspect
def countdown(start):
current = start
while current > 0:
received = yield current
if received is not None:
current = received
else:
current -= 1
gen = countdown(3)
print(inspect.getgeneratorstate(gen))
print(next(gen))
print(inspect.getgeneratorstate(gen))
print(gen.gi_frame.f_locals)
print(gen.send(10))
print(gen.gi_frame.f_locals)
gen.close()
print(inspect.getgeneratorstate(gen))
预期状态会从 GEN_CREATED 变为 GEN_SUSPENDED,关闭后变为 GEN_CLOSED。第一次 next() 之后,current 仍保存在生成器关联帧的局部状态中;send(10) 则让 yield 表达式产生值并改变后续执行。
这也给出了几道源码自测题:
- 为什么刚创建生成器时,函数体尚未真正执行?
yield返回给调用方时,哪些执行信息必须留下?send(value)中的值如何成为暂停位置上yield表达式的结果?close()如何让生成器执行清理路径?
阅读实现时,不要只盯着生成器对象本身。把生成器、帧、代码对象和求值循环放在一起,才能看清暂停与恢复的完整协议。
把源码阅读变成可重复的验证流程
一套实用流程可以这样执行:
# 假设已经取得 CPython 3.8 源码,并进入仓库目录
./configure --with-pydebug
make -j2
# 使用刚编译的解释器运行实验,而不是系统 Python
./python -c "import sys; print(sys.version); print(sys.gettotalrefcount())"
./python -m dis your_example.py
--with-pydebug 适合源码学习和调试,但生成的解释器更慢,不应拿来代表发布构建的性能。不同系统还可能需要编译器和开发库;运行前先确认 ./python 输出确实属于目标源码树。
每研究一个概念,都可以固定做四件事:先写最小 Python 样例,再用 dis、inspect、gc 或调试器观察;随后在源码中找到结构体和关键调用路径;最后改变一个输入,验证自己的解释能否预测结果。若预测失败,应回到实验,而不是继续堆积术语。
采用这套方法时的边界
CPython 3.8 的讲解非常适合建立解释器心智模型,但它不是当前所有 Python 版本的精确说明。较新的 CPython 已经改变部分字节码、调用约定和解释器优化策略,其他 Python 实现也未必使用相同的对象布局或内存管理方式。
实际学习时可以保留一份检查清单:确认解释器版本;区分语言规范与 CPython 实现细节;用目标版本生成字节码;避免依赖未公开的内部结构;把性能判断交给基准测试。做到这些,源码阅读就不再是文件漫游,而会变成一组能够复现、质疑和修正的工程结论。