Python 字节码与求值循环:函数是什么、解释器在循环什么
更新时间:2026-09-02。本文回答:一个
.py文件到底被翻译成了什么?dis.dis()打印出来的LOAD_FAST/BINARY_ADD是什么东西?为什么 CPython 跑循环天生比 C 慢?把循环里反复用的全局变量「先存成本地变量」为什么能变快?一次普通函数调用,解释器背后偷偷做了多少事? 这些问题都指向 CPython 的执行核心:源码被编译成字节码(bytecode),交给一个叫求值循环(eval loop,也称 ceval 循环)的巨型switch逐条解释执行。理解了它,你对「Python 为什么慢、怎么写才快」就有了第一性原理的判断,而不是背经验法则。本文 5 个 demo 全部在服务器(CentOS 7,Python 3.6.8)实测,代码见文末「代码位置」。
一、.py 不直接执行:先编译成 code object
很多人以为 Python「逐行解释、逐行执行」。更准确的说法是:CPython 在执行一个模块或函数前,先把它的源码编译成一种中间表示——字节码,然后由虚拟机解释字节码。这个过程对用户透明(编译结果缓存在 .pyc 里),但完全可观测。
编译的产物是一个 code object(types.CodeType)。函数对象(function)和 code object 是两层:函数对象是运行时的「外壳」,携带 __globals__、默认参数、闭包等上下文;而 f.__code__ 才是编译出来的、可被求值循环执行的那段字节码与元数据。同一个 code object 可以被多个函数对象共享,也可以被 exec/eval 反复执行。
code object 上有几组关键元数据,它们就是字节码的「常量表」和「名字表」:
co_varnames:局部变量名(含参数),按槽位编号——局部变量是按数组下标访问的;co_names:全局变量名、属性名、函数名——这些要按名字查字典;co_consts:字面量常量(数字、字符串、None等),LOAD_CONST按下标取;co_code:字节码本身(一串字节);co_stacksize:求值栈的峰值深度;co_nlocals:局部变量个数。
demo1(d1_code_object/bytecode.py)定义一个引用了全局 BASE 的小函数,dis.dis(f) 打印它的字节码,并把这些表逐一列出:
=== dis.dis(f): the bytecode the eval loop executes ===
7 0 LOAD_FAST 0 (a)
2 LOAD_FAST 1 (b)
4 BINARY_MULTIPLY
6 LOAD_GLOBAL 0 (BASE)
8 BINARY_ADD
10 STORE_FAST 2 (total)
8 12 LOAD_FAST 2 (total)
14 RETURN_VALUE
co_varnames (args + locals): ('a', 'b', 'total')
co_names (globals/attrs): ('BASE',)
co_consts (literals): (None,)
co_stacksize (peak stack): 2
type(f) = function
type(f.__code__) = code
f(6,7) = 142 (6*7 + BASE=100)读懂这张表是理解一切的基础。第一列数字是源码行号;第二列是字节码偏移量(每条指令在 3.6 里占 2 字节:1 字节操作码 + 1 字节参数,所以偏移是 0、2、4……);第三列是操作码(opcode);括号里是参数的人类可读注解。total = a * b + BASE 被翻译成:压入 a、压入 b、相乘、压入全局 BASE、相加、存入局部槽 2。注意 a/b/total 走 LOAD_FAST/STORE_FAST(下标访问),而全局 BASE 走 LOAD_GLOBAL(名字查找)——这个区别是后面性能差异的根源。
编译期就决定的事:局部变量有几个、叫什么、按什么槽位存放,常量有哪些,全局引用了哪些名字——全部在编译时固定下来。这也是为什么 Python 作用域里「一个名字在函数内是否被赋值」决定了它是局部还是全局(见后文坑速查)。
二、CPython 是一台栈式虚拟机
字节码不是随机排列的,它运行在一个求值栈(value stack)上。CPython 是典型的栈式虚拟机(stack machine):指令大多不显式带操作数,而是约定「从栈顶弹出操作数、把结果压回栈顶」。
这带来一个漂亮的性质:运算符优先级被编码成了操作数在栈上的先后顺序,不需要括号 AST。demo2(d2_stack_machine/stack.py)反汇编 return a + b * c:
=== dis of 'return a + b * c' ===
6 0 LOAD_FAST 0 (a)
2 LOAD_FAST 1 (b)
4 LOAD_FAST 2 (c)
6 BINARY_MULTIPLY
8 BINARY_ADD
10 RETURN_VALUE按后缀(逆波兰)读就是 a b c * +:压 a、压 b、压 c;BINARY_MULTIPLY 弹出栈顶两个(b、c)算 b*c 压回;BINARY_ADD 再弹出 a 和 (b*c) 算 a+(b*c)。乘法先算,不是因为有什么「优先级标志位」,而是因为 b*c 的结果先被压在栈顶、加法后执行时正好取到它。赋值语句则用 STORE_FAST 把栈顶弹出写进局部槽、LOAD_FAST 再读回来。
三、求值循环为什么慢:每条字节码都有固定开销
C 代码编译成机器码后,a + b 就是一两条原生指令,纳秒级。而在 CPython 里,同样的加法要走:求值循环取出 BINARY_ADD 操作码 → switch 跳到对应分支 → 从栈顶弹出两个 PyObject* → 调用 PyNumber_Add → 做类型检查、动态分派(整数走整数加法、浮点走浮点加法)→ 分配/复用结果对象 → 压回栈。一次「加法」对应几十到上百条 C 语句,而且每个值都是堆上的对象、每次运算都可能分配内存。这就是「Python 慢」的第一性原因:不是字节码本身慢,而是字节码之间的解释开销 + 一切皆对象的动态分派无法被 JIT 消掉(标准 CPython 没有 JIT)。
理解到这一层,性能优化就有了明确方向:减少求值循环要执行的字节码条数、减少循环内的动态分派、让热点数据走最快的访问路径。 下面两个 demo 量化最常见的两类开销。
3.1 局部变量快于全局变量:下标 vs 字典
LOAD_FAST i 是「从当前帧的 fastlocals 数组第 i 格取指针」,一步到位;LOAD_GLOBAL name 是「拿名字去帧的 globals 字典查,查不到再去 builtins 字典查」,是一次(偶尔两次)哈希查找。在热循环里这个差距会累积。经典优化手法:把循环内反复使用的全局值,在函数开头绑定到一个局部变量。demo3(d3_local_vs_global/localfast.py)两个 200 万次循环,唯一差别是累加的 G 走全局还是先存成本地 g:
loop iterations each: 2000000
global-access loop: 0.099 s
local-access loop: 0.082 s
speedup from binding to local: 1.21x反汇编清楚显示差异:全局版循环体里是 LOAD_GLOBAL 1 (G),本地版是 LOAD_FAST 1 (g)。约 1.2x 的差距看似不大,但这只是一条访问;真实代码里循环不变量(如 self.xxx、模块级常量、反复调用的 len/range)如果每次都走 LOAD_GLOBAL 或 LOAD_ATTR(属性访问也是字典查找 + 描述符协议,见 04 描述符与元类),累积起来很可观。这也解释了为什么很多高性能 Python 代码会在函数顶部写 _len = len、append = lst.append 这类「别名」。
3.2 函数调用开销:每次调用新建一个栈帧
第二类开销更贵:函数调用。CALL_FUNCTION 不只是跳转到字节码,它要创建一个新的栈帧对象(frame object)、准备 fastlocals 数组、传入参数、挂接 f_back 调用链,调用结束再销毁。在一个百万次的循环里反复调用一个小函数,等于百万次栈帧的建与销。demo4(d4_call_overhead/callcost.py)对比「循环里调用 square(i)」与「直接内联 i*i」:
loop iterations each: 2000000
call loop: 0.329 s (result=2666664666667000000)
inline loop: 0.164 s (result=2666664666667000000)
results equal: True
overhead of a call: 2.01x slower结果完全一致,但调用版慢了约 2 倍——这 2 倍几乎全是栈帧与调用约定的开销,乘法本身没区别。实践结论不是「不要写函数」(函数的可读性、可测试性价值巨大),而是:热点内层循环里,把极小的、被调用上百万次的计算内联或手工提升;把循环不变量挪到循环外;能用向量化/内置 C 实现(如 sum、列表推导、NumPy)就别在 Python 层逐元素循环。 列表推导之所以比等价的 for + append 快,部分原因就是循环体在专用字节码里、append 的属性查找只做一次。
四、栈帧:调用链与名字解析的现场
每次函数调用,CPython 在运行时栈上压入一个 frame object,它是这次执行的「现场记录」。帧里保存着:f_code(正在执行的 code object)、f_locals/fastlocals(局部变量)、f_globals(模块全局字典)、f_builtins(内建名字字典)、f_back(调用者帧,形成链)、f_lineno(当前行号)。
名字解析的 LEGB 规则(Local → Enclosing → Global → Built-in)在帧层面就是:先查 fastlocals(局部/闭包),再查 f_globals,最后查 f_builtins。LOAD_GLOBAL 一条指令就封装了「先 globals 后 builtins」两步查找——这也是为什么覆盖内建名(比如自己定义个 list)会在全局层挡住内建。
demo5(d5_frame_introspect/frame.py)用 sys._getframe() 现场抓帧,沿 f_back 走调用链:
inside middle(): frame.f_code.co_name = middle
caller via f_back = top
f_lineno now = 18
frame chain (callee -> caller): depth -> middle -> top -> <module>
frame.f_globals has __name__ ? True
frame.f_builtins has print ? True
f.f_code.co_name for the module-level frame = <module>可以看到调用链 depth → middle → top → <module>(模块帧是链底),每帧都能拿到自己的 code object、行号,以及共享的 globals/builtins 字典。这套帧机制不只是调试器(pdb)、traceback、性能分析器(cProfile/sys.settrace)的基础,也解释了异常回溯为什么能打印完整调用栈——因为 f_back 链一直都在。
与 C 的对照:C 的调用帧是原生栈帧,返回地址、参数、局部变量都在寄存器/栈指针上,
call一条硬件指令完成,没有堆分配。Python 的帧是堆上的对象,需要解释器手工搭建和回收,这正是调用开销大、且递归容易触及上限(sys.getrecursionlimit(),默认约 1000)的原因——每一层递归都是一个堆帧。
五、交叉引用与延伸阅读
| 你想接着了解 | 去读 |
|---|---|
LOAD_ATTR 背后的属性查找、property/绑定方法为什么也走协议 | 04 · 描述符与元类 |
字节码里 IMPORT_NAME/导入缓存、模块如何成为对象 | 03 · 导入机制与包工程 |
每条字节码操作的 PyObject* 到底是什么、引用计数与对象内存布局 | 02 · CPython 内部机制 |
| GIL 为什么让字节码级别的多线程无法并行、求值循环与线程切换 | 00 · GIL、多进程与 asyncio |
装饰器在字节码层面是「函数定义后立即 CALL_FUNCTION 一次」 | intermediate 00 · 生成器与装饰器 |
| C 程序如何编译成原生机器码(对照「为什么没有解释开销」) | C 01 · 编译、汇编与运行 |
| 性能分析方法:先用 cProfile 找热点字节码,再谈优化 | 01 · 性能优化路径 |
六、坑速查
- 「局部变量在赋值前被引用」
UnboundLocalError:编译期就定了一个名字是局部还是全局——只要函数体内任何地方对它赋值,整个函数里它都是局部。在赋值前读取就报UnboundLocalError,哪怕同名全局存在。要改全局必须global x(改外层闭包变量用nonlocal)。这本质是co_varnames在编译期就把它收编了。 - 以为
dis看到的就是执行速度:字节码条数只是粗略指标。真正贵的是指令背后的 C 操作(属性查找、函数调用、对象分配)。一条LOAD_ATTR可能比十条LOAD_FAST还贵。优化要 profile 驱动,别数字节码。 - 在热循环里反复
global访问 /self.x/len(obj):这些都走字典查找或描述符协议。把循环不变量提到循环外、绑成局部别名。 - 递归当循环用:Python 每个调用是堆帧,
sys.getrecursionlimit()默认约 1000,深递归既慢又易RecursionError。用迭代或显式栈。 - 过度内联损害可读性:demo4 的 2x 是在「百万次极小调用」的极端场景。普通业务代码里函数调用开销可忽略,先保可读性,用 cProfile 定位到真热点再优化。
- 字节码不是稳定 ABI:opcode 在版本间会变(如 3.11 大改、3.12 引入更细粒度指令)。本文输出基于 3.6.8;写依赖字节码偏移的工具要按版本适配,用
dis的符号名而非硬编码偏移。
七、代码位置
本文 5 个 demo 均在服务器(CentOS 7,Python 3.6.8)实测通过,仓库路径 demos/python-expert/bytecode-eval/:
| Demo | 文件 | 演示内容 |
|---|---|---|
| d1 | d1_code_object/bytecode.py | dis.dis 反汇编、co_varnames/co_names/co_consts/co_stacksize、函数对象 vs code object |
| d2 | d2_stack_machine/stack.py | 栈式虚拟机:a+b*c 的后缀入栈顺序即优先级、STORE/LOAD_FAST |
| d3 | d3_local_vs_global/localfast.py | LOAD_FAST vs LOAD_GLOBAL 实测(200 万次循环,局部绑定快约 1.2x) |
| d4 | d4_call_overhead/callcost.py | 函数调用(栈帧)开销实测:内联 vs 调用,约 2x |
| d5 | d5_frame_introspect/frame.py | sys._getframe、f_back 调用链、f_globals/f_builtins 名字解析 |
服务器上运行:进入 /tmp/bytecode-eval(或仓库对应目录),用 python3 <demo路径>/xxx.py 逐个运行;run_all.sh 为批量脚本(运行前需 sed -i 's/\r$//' 去除 Windows 换行,或用 bash 直接执行 LF 版本)。
八、动手练习
- 用
dis.dis反汇编一个列表推导[x*2 for x in range(10)],对比等价的for+append循环,找出LIST_APPEND等专用指令,解释为什么推导更快。 - 写一个函数,内部既读全局又对同名变量赋值,触发
UnboundLocalError;用co_varnames验证该名字已被编译为局部,再用global修复并重新dis观察指令变化。 - 给 d4 的
square加上@functools.lru_cache,再跑循环(注意入参重复率),观察缓存如何把「调用开销」换成「字典查找开销」,什么场景下反而变慢。 - 用
sys._getframe().f_back.f_lineno写一个迷你assert,在断言失败时打印调用者的文件名和行号(这就是很多断言库的原理)。 - 反汇编一个含
try/except的函数,找到SETUP_EXCEPT类指令,体会异常处理在字节码层面的成本(为什么「用异常做正常控制流」在 Python 里尤其慢)。