Skip to main content

Chapter 27: Execution Engine

Python 源码经过 tokenizer、parser、AST、symbol table、CFG 和 bytecode generation 之后,最终会落到一个更直接的问题:一个 code object 如何在 CPython 中被执行。执行引擎负责把 code object 放入 frame,沿 bytecode 指令流推进,维护 value stack,并在每条 opcode handler 中完成名字读取、属性访问、函数调用、异常传播和返回。

本章的目标是建立一条可追踪的执行链:Python 代码现象 → code object → frame → evaluation loop → opcode dispatch → value stack → runtime dispatch → eval breaker。读完本章后,读者应能定位一段 Python 代码进入 CPython 执行器后的关键状态,判断某个行为属于 bytecode 分派、对象协议分派、frame 状态变化,还是跨指令检查点触发的 runtime 事件。

本章默认以 CPython 为主。Bytecode 和 eval loop 都是 CPython 实现细节,dis 文档也明确说明 bytecode 会随 Python VM 和版本变化。Python 3.11 引入自适应解释器和更轻量的内部 frame,Python 3.13、3.14 又改变了 dis 输出、offset 展示和专门化显示方式。本章只建立稳定阅读模型,具体 opcode 名称、cache 布局和源码文件组织要以目标 CPython 版本为准。

贯穿材料使用下面这个短函数。它包含局部变量、循环、属性访问、二元操作和返回,足够覆盖 eval loop 中最常见的执行路径。

class Item:
def __init__(self, value):
self.value = value


def total(items):
acc = 0
for item in items:
acc += item.value
return acc

这段代码的执行器视角是:total.__code__ 提供静态指令和常量表,调用 total(items) 时创建当前 frame,frame 持有局部变量槽、value stack、当前指令位置、globals、builtins 和异常状态连接点。执行器沿 total 的指令流推进,每个 opcode handler 从 frame 中读取输入、更新栈或局部变量,并把结果留给下一条指令。

27.1 ceval.c

ceval.c 是阅读 CPython 执行引擎时的入口名,但现代 CPython 的执行器代码已经由多个文件共同构成。官方 CPython InternalDocs: The bytecode interpreter 把 bytecode interpreter 的入口定位到 Python/ceval.c,同时说明 opcode 的 switch case 由 Python/bytecodes.c 中的指令定义生成。阅读时应把 ceval.c 看成执行入口和调度中心,把 bytecodes.cgenerated_cases.c.h、opcode metadata、frame 和 thread state 结构一起纳入模型。

从公开 C API 进入执行器的路径可以概括为:PyEval_EvalCode() 接收 code object,构造执行所需的 frame,然后调用 _PyEval_EvalFrame()。默认情况下,_PyEval_EvalFrame() 会进入 _PyEval_EvalFrameDefault()。这个默认执行函数就是 CPython 主解释器循环的核心位置。根据 PEP 523,解释器的 frame evaluation function 可以被替换,所以源码阅读中还要区分“默认 CPython 执行器”和“通过 eval frame hook 接管的执行器”。

ceval.c 的主问题可以表述为:“当前 frame 中的一条指令如何被取出、解码、分派并执行”。例如 total(items) 进入执行器时,items 参数已经进入当前 frame 的局部变量区域,acc = 0 会被编译为加载常量和写入 fast local 的指令组合,acc += item.value 会在若干条 opcode handler 中依次完成局部变量读取、属性访问、二元操作和局部变量写回。

下面的图表达执行入口之间的层级关系;具体函数名和文件拆分应以目标 CPython 版本为准。

这条路径的关键是 frame。Code object 保存静态信息,例如 bytecode、常量、变量名和异常表;frame 保存动态执行状态,例如 instruction pointer、fast locals、value stack、globals、builtins 和当前异常状态。执行器每一步都围绕当前 frame 展开,所以调试执行行为时应先定位 code object,再定位 frame 中哪一部分状态发生变化。

27.2 evaluation loop

Evaluation loop 是执行 bytecode 的循环结构。它反复从当前 frame 的指令数组中读取一个 code unit,拆出 opcode 和 oparg,然后进入对应 opcode handler。CPython InternalDocs 给出的简化形态可以写成下面这样:

_Py_CODEUNIT *first_instr = code->co_code_adaptive;
_Py_CODEUNIT *next_instr = first_instr;

while (1) {
_Py_CODEUNIT word = *next_instr++;
unsigned char opcode = _Py_OPCODE(word);
unsigned int oparg = _Py_OPARG(word);

switch (opcode) {
/* one handler for each opcode */
}
}

这段伪代码展示了三个事实。第一,指令读取来自 code object 中的 bytecode 存储。第二,next_instr++ 让 instruction pointer 在进入 handler 前先指向后续位置。第三,handler 执行完成后通常回到循环顶部读取下一条指令;跳转、异常、返回和 yield 会改写这条默认路径。

total(items)dis 中可以观察到类似下面的指令形状。具体 opcode 名称会随版本改变,下面只用于展示执行器要处理的阶段。

LOAD_CONST 0
STORE_FAST acc
LOAD_FAST items
GET_ITER
FOR_ITER loop_end
STORE_FAST item
LOAD_FAST acc
LOAD_FAST item
LOAD_ATTR value
BINARY_OP +=
STORE_FAST acc
JUMP_BACKWARD for_iter
LOAD_FAST acc
RETURN_VALUE

Evaluation loop 面对的是一串可执行操作:把常量压栈,把栈顶写入局部变量,从局部变量读取对象,取得 iterator,按 FOR_ITER 推进,读取属性,执行二元操作,写回局部变量,然后根据跳转继续或退出。源码层面的 for+= 在这里已经被拆成了可由 stack VM 执行的局部步骤。

执行循环还承担错误路径的切换。当某个 handler 设置异常状态并跳到异常处理路径时,evaluation loop 会根据 exception table、当前 frame 状态和栈状态进入 unwind 流程。普通返回、异常返回、yield 暂停和 Python-to-Python 调用内联都会让 loop 从线性取指转到专门路径。阅读 ceval.c 时,必须把“正常下一条指令”和“handler 改写控制流”分开看。

27.3 opcode dispatch

Opcode dispatch 负责把当前 opcode 映射到具体 handler。它解决的是“当前指令由哪段 C 代码执行”的问题。以 LOAD_ATTR 为例,dispatch 先让执行器进入属性访问相关 handler;进入 handler 之后,属性查找才会继续根据对象类型、descriptor、inline cache 和版本标签决定具体路径。

传统解释器可以用一个大 switch (opcode) 完成分派。每次 loop 读取 opcode 后进入对应 case,handler 执行完成,再跳回分派点读取下一条指令。这个结构清晰,适合作为阅读模型;它的成本是每条指令都会回到同一个集中分派点,CPU 分支预测和间接跳转会受到影响。

现代 CPython 的 opcode handler 来源还涉及生成流程。Python/bytecodes.c 中的指令定义经过工具生成 Python/generated_cases.c.h 等文件,最终被纳入执行器。这个设计让 opcode 语义、stack effect、inline cache 布局和生成代码之间保持一致。阅读源码时,可以先从 dis 看到 opcode 名称,再到 bytecodes.c 找指令定义,最后回到生成后的 handler 形状理解执行路径。

PEP 659 之后,opcode dispatch 还要考虑 adaptive opcode 和 specialized opcode。LOAD_ATTR 这类指令可以在运行一段时间后变成更具体的专门化形式。Dispatch 看到的是“当前字节码数组中此刻的 opcode”,所以专门化会直接影响下一次进入哪个 handler。dis.dis(..., adaptive=True) 和 Python 3.14 的 python -m dis -S 可以用于观察专门化后的形态,但这些输出仍然属于 CPython 版本相关的诊断材料。

total(items) 中,LOAD_ATTR value 可能起初走通用属性读取路径;当运行时发现对象形状稳定,解释器有机会把它改写为更具体的属性读取指令,并把必要信息放入 inline cache。Opcode dispatch 因此连接了两层信息:编译器生成的基础指令流,以及解释器根据运行时对象形态改写出来的执行形态。

27.4 stack VM 的运行方式

CPython bytecode interpreter 是 stack machine。大多数指令通过 frame 内的 value stack 交换中间值:读取动作把对象引用压栈,消费动作从栈顶弹出对象,计算动作弹出输入并压回输出。这里的栈元素是对象引用,核心对象仍然位于 Python 堆对象系统中。

Code object 中的 co_stacksize 由编译器计算,用来决定当前 frame 需要多大的 value stack。每条 opcode 的栈效果也可以由 opcode metadata 或 dis.stack_effect() 这类工具观察。执行器依赖这些静态性质保证栈高度在合法指令序列中可追踪。非法或手工构造的 bytecode 如果破坏栈高度约束,就会离开普通编译器保证的安全范围。

acc += item.value 可以按 value stack 观察为下面的局部过程。这个表使用概念化栈内容说明,具体 Python 版本的精确 opcode 名称以目标版本为准。

阶段指令形状value stack 变化作用
读取左值当前对象LOAD_FAST acc[] → [acc_obj]把局部变量槽中的 acc 对象引用压栈
读取属性所属对象LOAD_FAST item[acc_obj] → [acc_obj, item_obj]把当前循环变量对象引用压栈
读取属性值LOAD_ATTR value[acc_obj, item_obj] → [acc_obj, value_obj]消费 item_obj,压入属性读取结果
执行二元操作BINARY_OP +=[acc_obj, value_obj] → [new_acc_obj]根据对象类型和数字协议计算结果
写回局部变量STORE_FAST acc[new_acc_obj] → []弹出结果并更新 fast local 槽

这个过程说明了 stack VM 的一个稳定判断:表达式内部的中间结果通常先通过 value stack 连接,名字绑定点再把栈顶对象写回 namespace 或 fast locals。acc += item.value 的最终效果看起来像“一步更新变量”,执行器内部却分为读取、属性访问、计算和写回多个 handler。

Value stack 与 call stack 属于两个层级。Value stack 是单个 frame 内部的表达式求值工作区;call stack 是多个 frame 的嵌套执行关系。total(items) 调用另一个 Python 函数时,执行器会切换到被调用函数的 frame;acc += item.value 这种表达式求值则主要在当前 frame 的 value stack 上完成。

27.5 instruction pointer 如何推进

Instruction pointer 记录当前 frame 执行到 bytecode 的哪个位置。CPython InternalDocs 的简化循环里,next_instr 在读取当前 code unit 后立即自增,所以进入 handler 时,指针通常已经指向后续指令。普通 handler 如果没有改写控制流,执行完成后下一轮 loop 会读取这个后续位置。

跳转指令通过改写 next_instr 改变推进方向。JUMP_FORWARD 这类指令把指针向后移动,JUMP_BACKWARD 把指针移回循环头附近。FOR_ITER 同时承担 iterator 推进和分支选择:成功取得下一个元素时继续进入循环体,收到正常迭代结束信号时跳到循环结束位置并整理栈状态。

Inline cache 会影响指针推进。专门化或可专门化指令后面可能保留若干 cache entry,这些 entry 位于 bytecode 数组中,但它们是 handler 的附属数据区。执行该指令时,handler 需要跳过自己的 cache entry,让 next_instr 指向下一条真实指令。Python 3.12 以后,跳转参数和 cache entry 的相对关系在 dis 输出中也有版本差异,所以阅读 offset 时要同时看目标版本的 dis 文档。

异常路径会通过 exception table 改写 instruction pointer。Python 3.11 之后,异常处理元数据从传统 block stack 模型转向表驱动路径。某个 handler 抛出异常后,执行器会结合当前指令位置和 exception table 找到处理范围,调整栈状态,并跳到 handler 指定位置。这解释了为什么同一段源码在正常路径下线性推进,在异常路径下会突然进入 exceptfinally 或 cleanup 代码。

Yield 和 resume 让 instruction pointer 具有暂停语义。生成器执行到 YIELD_VALUE 时,会保存可恢复位置和必要异常状态,把控制权还给调用者;下一次 send()next() 时,执行器从保存的位置继续。这里的指针推进已经超出单次函数调用的线性返回模型,frame 的所有权也会从线程栈转移到 generator object 内部的 _PyInterpreterFrame

27.6 computed goto

Computed goto 是解释器分派优化。传统 switch-case 每执行一条指令都回到集中分派点;computed goto 使用编译器扩展提供的“label as value”,为不同 opcode 建立直接跳转目标。每个 handler 结尾可以直接跳到下一条指令对应的 handler,从而降低集中 switch 分派带来的分支成本。

CPython InternalDocs 把解释器类型分为传统 switch-case、computed-gotos interpreter 和 tail-calling interpreter。Computed goto 通过 configure 选项 --with-computed-gotos 启用,并在支持的编译器上默认使用。传统 switch-case 仍是可用后备路径,因为不同编译器、平台和构建选项对扩展语法的支持不同。

Computed goto 优化的是 opcode dispatch 的控制流形状。它让“从当前 handler 到下一个 handler”的路径更短,也有利于 CPU 为不同 opcode 形成更稳定的预测历史。Python 语义仍保持一致:LOAD_ATTR 仍要执行属性访问语义,CALL 仍要进入调用协议,BINARY_OP 仍要按对象类型和数字协议求值。

total(items) 中,computed goto 影响的是 LOAD_FAST → LOAD_ATTR → BINARY_OP → STORE_FAST 这些 handler 之间如何跳转。它影响 handler 间跳转;item.value 是否命中 descriptor、acc += value 是否调用某个类型的数字 slot,仍由对象语义分发决定。分派优化和对象语义分发是两层成本来源,性能分析时要分别定位。

当前 CPython 主分支还包含 tail-calling interpreter 方向。它把单个 opcode 的实现拆成小 C 函数,并依赖编译器尾调用能力在 handler 之间转移控制权。这个方向属于实现策略变化,本章的稳定模型仍成立:执行器仍然需要取指、分派、维护 frame、操作 value stack,并在 handler 中执行 Python 语义。

27.7 runtime dispatch

Runtime dispatch 是进入 opcode handler 之后发生的分派。Opcode dispatch 只回答“当前指令进入哪个 handler”;runtime dispatch 回答“这个 handler 面对当前对象时走哪条具体语义路径”。Python 的动态性主要落在这一层:同一条 BINARY_OP 可能处理 intfloatlist、用户自定义类型;同一条 LOAD_ATTR 可能命中实例属性、data descriptor、non-data descriptor、类属性、__getattr__ 或专门化缓存。

total(items) 中的 LOAD_ATTR value 是典型例子。Handler 先拿到栈顶的 item_obj 和属性名索引,再根据对象类型执行属性查找。稳定情况下,inline cache 可以记录类型版本、dict keys 版本、slot offset 或属性位置,让后续访问减少重复查找。缓存命中时路径短;对象类型或字典形状变化时,guard 失败,handler 回退到通用路径或触发 de-optimization。

BINARY_OP += 也体现 runtime dispatch。Opcode 层只知道要执行某种二元操作;对象层还要决定是否走整数快速路径、浮点路径、序列拼接、原地操作协议、反向操作协议或错误路径。PEP 659 的专门化解释器会尽量让稳定类型组合进入更短的 specialized handler,但语义仍要保持 Python 对象协议的结果。

CALL 的 runtime dispatch 更复杂。被调用对象可能是 Python function、bound method、built-in function、class、C extension callable 或实现了 __call__ 的实例。CPython 使用 vectorcall 等协议减少参数包装成本;从 Python 3.11 开始,Python-to-Python 调用还可以在解释器内联路径中推入新的内部 frame,并在 RETURN_VALUE 时回到调用者 frame。这里的“内联”指执行器 frame 切换策略,含义接近“在解释器循环内切换到被调 frame”,区别于编译器把函数体复制到调用点。

下面的图把 opcode dispatch 与 runtime dispatch 分开表示。一个 opcode handler 可以包含多个对象语义分支,inline cache 和 specialization 只是在其中压缩稳定分支的成本。

阅读执行器时,最容易混淆的是把 opcode 名称当成完整语义。例如 LOAD_ATTR 只说明“这里要读取属性”,完整结果还取决于对象类型、类层级、descriptor 优先级、实例字典状态和缓存状态。稳定方法是先判断当前 opcode,再进入对象协议层判断具体分支。

27.8 eval breaker

Eval breaker 是跨指令检查点。Python 程序运行时,有些事件来自当前 bytecode 流之外,例如 signal、pending call、GIL drop request、async exception、monitoring 或解释器停止请求。执行器需要在安全位置观察这些事件,并把它们转成同步处理动作。Eval breaker 把多类“需要打断正常取指循环”的条件汇合成可快速检查的位状态。

GIL 相关源码注释中明确提到,主循环需要响应其他线程请求释放 GIL 的信号,并且 eval_breaker 会把多个条件通过 OR 汇合到一个检查入口。当前 CPython 中,pending calls、signals 和 GIL 请求等状态会设置 thread state 上的 eval breaker bit;执行器在选定指令边界检查这些 bit,然后调用对应处理函数。这样可以把异步发生的外部事件转成解释器可控的同步处理点。

Eval breaker 的边界是“安全点”。CPython 会在 handler 设计允许的位置检查并处理事件,普通 C 函数内部通常要等控制权回到解释器边界。比如长时间执行 C 扩展函数时,Python bytecode loop 没有机会推进;纯 Python 紧密循环则会不断回到解释器检查点。sys.setswitchinterval() 影响 GIL 请求间隔,但实际切换仍受 opcode 执行时间和检查点位置约束。

考虑下面这个循环:

def spin_until(flag):
while not flag.done:
flag.tick += 1

源码层面它是一个 while 循环;执行器层面它反复执行属性读取、truth testing、属性读取、二元操作、属性写回和 backward jump。Eval breaker 通常会在循环回边、调用返回或其他选定边界参与检查。收到 signal、pending call 或 GIL drop request 时,执行器会暂时离开普通 opcode 推进路径,处理对应事件,再决定继续当前 frame、传播异常或切换线程。

因此,eval breaker 是理解“Python 代码为什么会在看似普通的循环中响应 Ctrl-C、线程切换或 pending call”的入口。它属于执行循环和 runtime state 的协作机制,会影响执行时机、异常出现位置和多线程调度表现。阅读异常堆栈或性能采样时,要把 eval breaker 视为执行循环和外部 runtime state 之间的连接点。

最小自检任务

阅读下面代码,并从执行引擎角度回答四个问题:total 调用时动态执行状态保存在哪里;acc += item.value 的 value stack 大致经历哪些变化;opcode dispatch 和 runtime dispatch 分别发生在哪一层;KeyboardInterrupt 或线程切换请求会通过哪类机制进入执行循环。

class Item:
def __init__(self, value):
self.value = value


def total(items):
acc = 0
for item in items:
acc += item.value
return acc

答案要点

total 的静态指令、常量和变量名保存在 code object 中;一次具体调用的动态状态保存在当前 frame 中。Frame 持有 fast locals、value stack、instruction pointer、globals、builtins 和异常状态连接点。执行器实际推进的是当前 frame 中的指令位置和栈状态。

acc += item.value 的 value stack 可以概括为:读取 acc 后压入 acc_obj;读取 item 后压入 item_obj;属性读取消费 item_obj 并压入 value_obj;二元操作消费 acc_objvalue_obj 并压入结果;STORE_FAST acc 弹出结果并写回 fast local 槽。具体 opcode 名称可能随 CPython 版本变化,但栈式连接关系保持稳定。

Opcode dispatch 发生在 evaluation loop 中,它根据当前 opcode 进入对应 handler。Runtime dispatch 发生在 handler 内部,它根据对象类型、slot、descriptor、vectorcall、inline cache 和 guard 结果选择具体语义路径。LOAD_ATTRBINARY_OP 都需要这两层共同完成。

KeyboardInterrupt、pending call 和线程切换请求这类外部事件会通过 eval breaker 进入执行循环。它们先设置 thread state 或 interpreter state 中的相关位,再由执行器在选定安全点检查并处理。处理结果可能是继续执行、设置异常、运行 pending callback 或响应 GIL 调度请求。

本章知识点总结

  • 执行入口:CPython 执行 code object 时会创建或使用 frame,并通过 _PyEval_EvalFrame() 进入默认或被替换的 frame evaluator。
  • 入口文件ceval.c 是执行器入口名,现代 opcode handler 还依赖 bytecodes.c、生成文件、metadata、frame 和 thread state 结构。
  • 动态状态:Code object 保存静态指令,frame 保存当前调用的 instruction pointer、fast locals、value stack 和异常状态连接点。
  • 循环模型:Evaluation loop 反复读取 code unit,拆出 opcode 和 oparg,然后进入对应 handler。
  • 分派层级:Opcode dispatch 选择 handler,runtime dispatch 在 handler 内根据对象类型和协议选择具体语义路径。
  • 栈式求值:Value stack 连接表达式中间结果,名字读取、属性访问、二元操作和写回通过压栈与弹栈衔接。
  • 调用层级:Value stack 属于单个 frame 的表达式工作区,call stack 表示多个 frame 之间的调用嵌套。
  • 指针推进:普通指令沿后续位置推进,跳转、异常表、yield/resume 和 inline cache 会改写 instruction pointer 的推进方式。
  • 缓存影响:Inline cache 位于 bytecode 数组中,handler 需要跳过自己的 cache entry,并在稳定对象形态下复用缓存信息。
  • 分派优化:Computed goto 优化 opcode handler 之间的跳转成本,Python 语义仍由 handler 和对象协议保证。
  • 专门化路径:PEP 659 让解释器根据运行时对象形态改写 opcode,并在 guard 失败时回退或重新适配。
  • 检查点:Eval breaker 汇合 signal、pending call、GIL drop request 和 async exception 等事件,让外部 runtime state 在安全点进入执行循环。