从字节码到生成器:用可运行实验读懂 CPython 3.8 源码

2026-08-20 28 预计阅读时间: 1 分钟
来源: realpython.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:9 分钟

读 CPython 源码最容易陷入两个极端:要么从数十万行 C 代码的入口硬啃,要么只记住几个名词,却说不清字节码、栈帧、引用计数和生成器如何连成一条执行链。更有效的办法,是把源码问题变成可以运行、观察和验证的小实验。本文以 CPython 3.8 的实现视角为边界,用一组自测问题和 Python 标准库工具搭起源码阅读地图。

一段 Python 代码究竟经历了什么

先看最关键的路径:源代码被编译成代码对象,代码对象保存字节码及相关元数据;解释器创建执行帧,再由求值循环逐条处理指令。函数调用、局部变量访问、异常处理和生成器暂停,都能放回这条路径中理解。

可以先回答三个问题:

  1. LOAD_FAST 为什么通常比按名称查找全局变量直接?
  2. 函数对象和代码对象有什么区别?
  3. 解释器执行一条调用指令时,参数、返回地址和执行帧分别由谁管理?

下面的脚本可直接运行。建议使用 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)

运行时重点观察 pricecountsubtotal 如何出现在 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 表达式产生值并改变后续执行。

这也给出了几道源码自测题:

  1. 为什么刚创建生成器时,函数体尚未真正执行?
  2. yield 返回给调用方时,哪些执行信息必须留下?
  3. send(value) 中的值如何成为暂停位置上 yield 表达式的结果?
  4. 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 样例,再用 disinspectgc 或调试器观察;随后在源码中找到结构体和关键调用路径;最后改变一个输入,验证自己的解释能否预测结果。若预测失败,应回到实验,而不是继续堆积术语。

采用这套方法时的边界

CPython 3.8 的讲解非常适合建立解释器心智模型,但它不是当前所有 Python 版本的精确说明。较新的 CPython 已经改变部分字节码、调用约定和解释器优化策略,其他 Python 实现也未必使用相同的对象布局或内存管理方式。

实际学习时可以保留一份检查清单:确认解释器版本;区分语言规范与 CPython 实现细节;用目标版本生成字节码;避免依赖未公开的内部结构;把性能判断交给基准测试。做到这些,源码阅读就不再是文件漫游,而会变成一组能够复现、质疑和修正的工程结论。


相关推荐