Skip to main content

Chapter 69: ceval.c

ceval.c 是读 CPython 执行器时绕开的代价最高的入口。前面章节已经把源码转换成 code object、symbol table、bytecode 和 exception table,本章把这些静态产物接回运行时:解释器怎样拿到一个 frame,怎样逐条执行 bytecode,怎样维护 value stack,怎样在调用、返回、异常、生成器暂停和外部事件之间切换状态。

读完本章后,读者应能定位一个 Python 代码现象在 CPython 执行器中的落点:先判断当前执行对象是哪一个 frame,再判断 opcode 怎样分派,再判断 value stack 和 fast locals 怎样变化,再判断异常、返回和 eval breaker 怎样改变控制流。本章默认以 CPython 为主,涉及的实现细节以 Python 3.11 之后的 specializing adaptive interpreter 为边界;文件组织在不同版本中会变化,源代码阅读时应同时看 Python/ceval.cPython/bytecodes.c、生成出的 instruction cases、Python/specialize.c 以及相关 internal headers。

贯穿材料使用这个短函数。它覆盖局部变量、循环、函数调用、数值运算、返回值和异常路径,足够观察 ceval.c 负责的主干状态:

def outer(limit):
total = 0
for index in range(limit):
total += index
try:
return total / limit
except ZeroDivisionError:
return None

result = outer(3)

从语言层看,这段代码只是调用 outer 并得到一个结果。从 CPython runtime 看,过程分成两层:module 顶层 frame 执行函数定义和调用表达式,outer 调用建立新的执行 frame,evaluation loop 在这个 frame 上推进指令。每条指令只处理当前一步,跨指令的连续性由 frame、instruction pointer、value stack、fast locals、exception state 和 thread state 共同保存。

本章提到的 specialization 可参考 PEP 659Python 3.11 Faster CPython 说明。源码入口可从 CPython Python/ceval.c 开始,但当前主分支会把部分 opcode 行为拆到生成文件或 instruction definition 中;阅读结论应以当前检出的源码树为准。

69.1 evaluation loop

Evaluation loop 是 CPython 把 bytecode 变成对象操作的主循环。它的输入不是源码文本,也不是 AST,而是当前 thread state 中正在执行的 frame。frame 持有 code object、locals 存储、value stack、当前指令位置和异常相关状态;loop 反复取下一条指令,按 opcode 进入处理代码,更新 frame,再决定继续、跳转、返回或进入异常路径。

用贯穿材料观察,outer(3) 进入函数后,limit 已经绑定到 fast locals 的某个槽位,total = 0 会把常量对象推入 value stack,再写入 total 对应的 fast local。for index in range(limit) 会执行名字读取、调用、迭代器获取和循环推进。total += index 会读取两个 fast locals,把两个对象放到 value stack,经由数值协议得到新对象,再写回 total。这些步骤在 Python 层看起来像一行语句,在执行器层会拆成多个 bytecode,每个 bytecode 都只承担一个局部动作。

Value stack 是理解 evaluation loop 的关键对象。它保存当前表达式还没有消费完的中间对象,例如函数对象、参数、二元运算两侧操作数、调用返回值。Fast locals 是 frame 内对局部变量的数组化存储,limittotalindex 这类编译期已知的局部名通常通过下标访问。Code object 中的 constants、names、varnames 等表为 opcode 参数提供索引。Evaluation loop 的工作就是在这些表、局部槽位和值栈之间搬运对象,并在需要时调用对象协议。

这条主路径可以压缩成一个阅读图:

图中的 fetch opcode → dispatch handler → update value stack 是正常路径。callreturnexception 这三个出口把当前 frame 接到其它 runtime 结构:调用会进入另一个 callable 的调用协议,返回会把结果交回调用方,异常会携带错误状态和 traceback 连接点继续传播。读 ceval.c 时,应把每个 opcode case 还原成这个图中的某个局部动作,读源码时应把整个文件中的宏和跳转标签放回同一条执行路径中。

Evaluation loop 的工程意义在于统一解释“语法为什么有成本”。total += index 的成本来自读取局部槽位、对象协议分发、结果对象处理和写回;outer(3) 的成本来自 callable dispatch、frame 建立和参数绑定;try/except 的正常路径成本在 Python 3.11 之后更多依赖 exception table,而异常发生时才进入更重的匹配与展开逻辑。这些成本都落在执行器对 frame 和对象的操作上。

69.2 opcode dispatch

Opcode dispatch 负责把当前 opcode 路由到对应处理代码。dispatch 的输入是 instruction pointer 指向的指令,输出是一个处理分支:例如 LOAD_FAST 读取 fast local 并压栈,STORE_FAST 弹出栈顶并写入 fast local,BINARY_OP 调用数值协议,CALL 进入调用路径,RETURN_VALUE 结束当前 frame。Dispatch 本身只决定“当前指令交给谁处理”,指令语义由 handler 对 frame 和对象状态的修改体现。

CPython 历史上可以使用 switch 分派,也可以在支持的编译器上使用 computed goto。switch 会让 C 编译器生成一个集中分支;computed goto 会把每个 opcode case 后的跳转直接指向下一条指令的处理入口,从而减少集中分支对分支预测的压力。两者改变的是 C 层控制流形态,Python 语义仍由相同的 opcode 规则决定。

从源码阅读角度看,Python 3.11 之后的 instruction 形态更加生成化。源树中可能出现 bytecodes.c、生成出的 cases、opcode metadata 和 ceval.c 配合工作的结构。阅读时先确认当前版本中 _PyEval_EvalFrameDefault 或等价执行入口在哪里展开,再沿着某个 opcode 的定义追到对象操作。以 LOAD_FAST 为例,阅读目标是确认它从 frame 的 fast locals 区读出对象并压入 value stack;以 CALL 为例,阅读目标是确认它如何从 stack 上取得 callable、self 或参数数组,再转入 vectorcall、Python function inline frame 或通用 tp_call 路径。

贯穿材料里的循环能展示 dispatch 的局部性。for index in range(limit) 需要先得到 range,再准备参数,再调用,再获取 iterator,再进入循环。每一步都有独立 opcode,dispatch 一次只处理其中一条。循环回边会把 instruction pointer 改到前面的迭代点,下一次 dispatch 继续取指令。源码中的标签、宏和 goto 看起来密集,核心仍是“取当前 opcode,执行一个局部状态变化,决定下一条指令地址”。

Opcode handler 读取和写入的对象通常分三类。第一类是 frame 内部对象,例如 instruction pointer、stack pointer、fast locals、localsplus。第二类是 code object 静态表,例如 consts、names、varnames、exception table、inline cache layout。第三类是 runtime 对象,例如当前 thread state、异常状态、callable 对象、type slot 和 interpreter state。一个 handler 使用哪一类对象,决定了它属于纯栈操作、名字访问、对象协议、调用路径还是运行时协调点。

读 opcode dispatch 时可以使用固定顺序:先看 opcode 从哪里取参数,再看它消耗几个栈元素,再看它产生几个栈元素,再看它是否读写 locals,再看它是否调用对象协议,再看它是否能跳转或抛出异常。这个顺序能把密集的 C 代码转回 Python 层可观察行为。

69.3 specialization

Specialization 是 CPython 在运行时根据实际对象类型和命名空间稳定性,把通用 opcode 替换成更窄、更快路径的机制。PEP 659 把这种思路称为 specializing adaptive interpreter:代码先以通用指令运行,执行次数达到阈值后尝试使用 specialized instruction;当输入形态变化时,指令可以退回更通用的形态。这里的关键判断是:specialization 保留 Python 语义,同时减少常见路径上的动态查找、分派和对象检查成本。

贯穿材料中至少有三个适合 specialization 的点。range(limit) 会读取 global 或 builtins 名字,稳定时可缓存 globals 或 builtins dict 的版本信息和命中位置。total += index 在两个对象长期是 int 时,可走整数加法的快路径。函数调用在 callable 形态稳定时,可减少通用调用协议中的部分检查。源码阅读时,看到 specialized opcode 或 inline cache,不应把它理解成一套新的 Python 语义;它是通用语义在某组前提成立时的快速实现。

Inline cache 是 specialization 能落地的存储位置。普通 bytecode 只有 opcode 和 operand,很多优化需要额外信息,例如 dict version、属性偏移、全局变量索引、call shape、counter。CPython 会把 cache entries 放在指令附近,让 handler 在执行时直接读取这些缓存。缓存命中时,handler 用少量 guard 验证前提;guard 成立就走快路径,guard 失败就降级到通用路径或触发 de-specialization。

Specialization 的生命周期通常包括四个阶段:初始通用执行、收集运行反馈、替换为 specialized instruction、根据 guard 结果维持或退回。这个过程解释了两个常见现象。第一,同一段代码第一次执行和多次执行的性能可能不同,因为 hot code 才会获得稳定缓存。第二,动态修改类、模块 globals 或对象布局可能让缓存失效,因为先前的命中位置已经无法代表当前语义。

这个机制对源码阅读有两个边界。其一,specialized instruction 的存在不改变语言规范;如果一个优化路径判断失败,执行器必须得到和通用路径一致的结果或异常。其二,specialization 的细节高度版本相关;Python 3.11 引入 PEP 659,后续版本持续调整 opcode、cache layout、superinstruction 和生成代码组织。因此,文章或工程排查中引用某个具体 opcode 名称时,应标注 Python 版本,并在当前源码树中确认名称和布局。

读 specialization 相关代码时,应把问题拆成三层。第一层问“这个 opcode 的通用语义是什么”,例如 LOAD_GLOBAL 要按 globals 再 builtins 的顺序查找名字。第二层问“cache 保存了哪种前提”,例如 namespace keys 的版本和命中 index。第三层问“guard 失败后怎样回到正确结果”,例如转入 generic lookup 或把指令改回 adaptive 形态。三层闭合后,才能判断某个优化是否影响当前 bug、性能瓶颈或调试观察。

69.4 frame execution

Frame execution 管理一个代码块运行期间的全部可恢复状态。一个 Python function 被调用时,CPython 会把 code object、globals、builtins、closure、参数绑定和 fast locals 区组合成 frame。Evaluation loop 不直接“执行函数对象”,而是执行函数对象创建出的 frame。这个 frame 记录当前指令位置和值栈,因此它可以在普通调用中推进,也可以在异常中挂到 traceback,还可以在 generator 或 coroutine 中暂停后恢复。

贯穿材料的 outer(3) 会建立一个函数 frame。limit 参数进入 fast locals,totalindex 在执行中被写入 fast locals,循环中间值进入 value stack。return total / limit 成功时,除法结果成为 return value,当前 frame 退出,调用方的 value stack 收到这个返回值。outer(0) 触发 ZeroDivisionError 时,执行器会根据异常表找到可处理范围和 handler,恢复栈到合适状态,把异常对象带入 except ZeroDivisionError 对应路径,最终返回 None

Python 3.11 之后,frame 还有一个容易误读的变化:内部 frame 和用户可见 frame object 的创建时机分离。执行器可以使用更轻的内部 frame 运行 Python 函数;当调试器、traceback、sys._getframe()inspect.currentframe() 需要用户可见对象时,才物化对应的 frame object。源码阅读时应区分 internal frame execution state 与 Python 层 frame 对象。前者是执行所需的最小状态,后者是调试和 introspection 暴露出来的对象视图。

函数调用也有版本相关的执行优化。Python 函数调用另一个 Python 函数时,CPython 可以建立新 frame 后在执行器内部跳到新 frame 的 code,减少一层 C 递归调用。这个优化解释了为什么 Python function call 的 C stack 消耗在 3.11 之后显著下降。语义层仍然是“调用创建新执行上下文”,实现层则把多个 Python frame 尽量放在同一个 C evaluator 活动范围内推进。

异常路径把 frame execution 和 traceback 连接起来。正常路径只需要沿指令推进;异常发生后,执行器要保存当前错误状态,查询 exception table,决定是否有 handler,必要时展开 frame,并把 traceback 链接到可见异常对象上。try/except 的存在会影响编译出的 exception table,异常发生时才消耗匹配和展开成本。读 ceval.c 时看到异常标签、unwind 或 error exit,应回到三个问题:哪个对象表示当前异常,哪个范围能处理它,当前 frame 的栈和指令位置需要恢复到哪里。

Frame execution 的检查顺序可以固定为:先确认当前 frame 的 code object,再确认 localsplus 中每个名字的位置,再确认 value stack 在当前 opcode 前后的形状,再确认指令位置是否跳转,再确认异常状态和 traceback 是否被更新。这个顺序能解释局部变量为何可见、返回值为何交给调用方、异常为何显示某一行、generator 为何能恢复到暂停点。

69.5 runtime state

Runtime state 是 evaluation loop 运行时依赖的全局和线程级上下文。Frame 保存“当前代码块怎样执行”,runtime state 保存“当前解释器和线程处在什么条件下执行”。在 CPython 中,常见对象包括 runtime、interpreter state、thread state、GIL 相关状态、pending calls、signals、recursion limit、async exception 和 eval breaker flags。一个 opcode handler 可以只操作 value stack,也可以因为调用、异常、信号或调度需求访问 thread state。

贯穿材料里的 outer(3) 在单线程、无信号、无 pending call 的场景下看起来只需要 frame。实际执行入口仍然从 thread state 出发,因为当前线程必须知道正在执行哪个 frame、当前异常是什么、递归深度还剩多少、是否有外部请求需要处理。total / limit 触发异常时,错误状态会记录在线程状态相关结构中;递归调用过深时,递归限制检查也依赖 thread state 中的计数和栈边界。

Interpreter state 表示一个解释器实例拥有的共享状态,例如 builtins、modules、import state、某些缓存和执行控制字段。Thread state 表示一个执行线程在某个解释器中的活动状态,例如当前 frame、异常状态、递归计数、tracing 状态和异步异常请求。GIL 相关状态控制同一解释器中 Python bytecode 的并发执行边界;Python 3.13 开始提供 free-threaded 构建方向,但默认 CPython 的读源码模型仍需把 GIL、thread state 和 eval loop 放在一起理解。更细的 free-threaded 变化属于后续 runtime evolution 章节。

Runtime state 的存在解释了为什么 evaluation loop 里会出现看似与当前 opcode 无关的检查。信号可能由操作系统异步送达,pending call 可能由 C API 或运行时安排,另一个线程可能请求调度,递归限制需要在进入更深调用前判断。执行器需要在安全位置处理这些事件,使 Python 层看到一致的异常、回调和调度行为。

ceval.c 的 runtime state 代码时,可以把对象责任分清。Frame 负责当前代码块内部状态;thread state 负责当前线程的执行上下文和错误状态;interpreter state 负责解释器共享资源;runtime 负责进程级或全局运行时结构。一个 bug 如果表现为局部变量错误,优先看 frame 和 opcode;如果表现为异常泄漏、递归限制、信号延迟或线程调度,优先看 thread state、interpreter state 和 eval breaker。

这组状态也给性能判断提供边界。纯 value stack 指令可以非常快,因为它们多在 frame 内完成。名字查找和属性访问会跨到 dict、type、descriptor 和 cache。调用会跨到 callable protocol、frame 建立或 C API。信号、pending call 和 GIL 调度会让执行器离开 opcode 快路径。定位瓶颈时,先判断成本停留在 frame 内,还是已经跨到对象协议、调用协议或 runtime coordination。

69.6 eval breaker

Eval breaker 是 CPython 在快速执行 bytecode 和处理外部事件之间设置的合并检查点。正常 opcode 执行追求低开销,外部事件又需要及时响应;eval breaker 把多类“需要打断当前快路径”的请求合并成一个标志检查,让 evaluation loop 在安全位置统一处理。它连接 signals、pending calls、GIL scheduling、async exception、tracing 或其它需要离开普通 dispatch 的事件。

安全位置是理解 eval breaker 的核心。执行器不能在任意 C 代码中随意切换状态,因为 value stack 可能处在中间形态,引用计数可能尚未配平,异常状态可能还没有设置完整。Eval breaker 通常在 opcode 边界、循环回边、函数调用附近或其它可恢复位置触发检查。这样做让外部事件获得响应,同时保持 frame 和对象状态一致。

用贯穿材料看,for index in range(limit) 的循环会不断回到某些可检查点。如果此时进程收到信号,或者运行时安排了 pending call,eval breaker 会让 loop 暂停普通 opcode 快路径,转入处理逻辑。处理完成后,如果没有异常需要传播,执行器回到当前 frame 继续推进;如果处理逻辑设置了异常,当前 frame 会进入异常路径,表现为 Python 层在某条字节码边界附近收到异常。

Eval breaker 与 GIL 调度也有关。持有 GIL 的线程执行 Python bytecode 时,其他线程可能需要获得执行机会。调度请求会通过运行时状态反映到检查点,当前线程在合适位置释放或切换。这个机制保证普通 opcode handler 不需要在每个对象操作中展开完整调度逻辑,调度责任集中到执行器的安全边界上。

源码阅读时,eval breaker 相关代码容易和信号、pending call、线程切换交织在一起。稳定读法是先确认“哪些事件会设置 breaker”,再确认“哪些位置会检查 breaker”,再确认“检查命中后调用哪个处理函数”,最后确认“处理结果怎样回到当前 frame 或转入异常”。这个顺序能解释 Ctrl-C 为何以 KeyboardInterrupt 进入 Python,C API 安排的 pending call 为何在解释器线程执行,线程调度为何不会破坏当前 value stack。

Eval breaker 的工程结论是:CPython 的执行器不是只做字节码运算,它还承担运行时协调责任。高频路径尽量留在 opcode handler 和 frame 内;低频但必须响应的外部事件集中到 breaker 检查点处理。读 ceval.c 时,把 eval breaker 看作“快路径与运行时请求之间的闸门”,就能把信号、pending call、GIL 和 async exception 放进同一张执行图里。

最小自检任务

阅读下面代码,不需要运行,也不需要查看 disassembly。请判断第二次调用 reader() 抛出异常时,CPython execution model 中哪些状态已经发生变化,异常怎样从当前 frame 回到调用方。

def read_twice(reader):
first = reader()
second = reader()
return first + second

counter = 0

def reader():
global counter
counter += 1
if counter == 2:
raise ValueError("stop")
return counter

read_twice(reader)

要求按这个顺序回答:当前有哪些 frame;第二次调用前 read_twice 的 value stack 和 fast locals 中大致保存什么;reader 抛出 ValueError 后异常状态怎样传播;specialization 和 eval breaker 在这个判断中各自承担什么角色。

答案要点

执行到第二次 reader() 前,至少存在 module frame 和 read_twice frame;进入第二次调用后,还会建立 reader frame。read_twice 的 fast locals 中保存参数 reader 和第一次调用结果 first,第二次调用表达式会把 callable 和调用所需状态放入 value stack,调用成功后才会把结果写入 second。由于第二次调用抛出 ValueErrorsecond 绑定不会完成,return first + second 也不会执行。

reader frame 中,global countercounter += 1 操作写回 module globals。第二次调用把 counter 更新到 2 后,条件成立并创建 ValueError。异常状态进入当前 thread state 相关错误路径,reader frame 没有本地 handler,于是 frame 退出并把异常传播到调用它的 read_twice frame。read_twice 也没有 handler,异常继续传播到 module frame,最终成为调用点可见的异常和 traceback 链。

Specialization 可以影响 LOAD_GLOBAL counterSTORE_GLOBAL counter、调用和整数加法的快路径选择,但它必须保持同样的异常和绑定语义。即使命中 specialized instruction,第二次调用仍然要更新 counter,仍然要在条件成立时抛出 ValueError。Eval breaker 不负责这段代码的普通函数调用语义;它只在信号、pending call、调度或异步异常等外部请求出现时,把 execution loop 从快路径带到运行时协调逻辑。

本章知识点总结

  • 执行入口ceval.c 相关执行器代码把 code object、frame、bytecode 和 thread state 连接成可推进的运行时状态。
  • 主循环:Evaluation loop 反复取当前 opcode,执行局部状态变化,再决定下一条指令、跳转、调用、返回或异常路径。
  • 值栈:Value stack 保存表达式中间对象,是 opcode 之间传递操作数和返回值的核心结构。
  • 局部槽位:Fast locals 用数组化槽位保存编译期已知局部名,降低普通局部变量读写成本。
  • 分派逻辑:Opcode dispatch 把指令路由到 handler,handler 通过栈、locals、code object 表和 runtime state 完成语义动作。
  • 调用路径CALL 类指令从 stack 取得 callable 和参数,再进入 vectorcall、Python frame 执行或通用 tp_call 路径。
  • 专项优化:Specialization 用 inline cache 和 runtime feedback 为稳定类型或稳定命名空间选择快路径。
  • 缓存边界:Inline cache 命中依赖 guard;guard 失败时执行器回到通用路径或退回 adaptive 形态,以保持 Python 语义。
  • Frame 状态:Frame 保存 code、locals、value stack、instruction pointer 和异常连接点,是函数执行、返回和 traceback 的共同基础。
  • 惰性对象:Python 3.11 之后内部 frame 与用户可见 frame object 分离,调试和 introspection 需要时才物化 frame object。
  • 异常展开:异常路径会记录错误状态,查询 exception table,恢复栈形态,并把 traceback 接到传播中的异常对象上。
  • 运行时上下文:Thread state 和 interpreter state 保存当前线程、解释器、异常、递归、信号、pending call 和调度相关信息。
  • Breaker 闸门:Eval breaker 把 signals、pending calls、GIL scheduling 和 async exception 合并到安全检查点处理。
  • 阅读顺序:读执行器源码时先定 frame,再看 opcode 栈效应,再看协议调用,再看异常和 runtime coordination。