码农高天
Python将不可变代码对象与运行时状态解耦至Frame链表中,虽然为调试和栈内省带来完全透明度,但维护调用帧的固有开销会让频繁的微小函数调用沦为性能瓶颈。
AI-assisted summary of 【python】python的骨架frame——你写的代码都是运行在它里面的? — original source. Check the original for full context.
Source published:
Key ideas
- Python 代码编译生成的 CodeObject 是不可变的静态元信息,而每一次函数调用都会生成独立的 frame 来记录动态的运行时状态与局部变量。 (0:00)
- Frame 逻辑上构成调用栈,底层通过 f_back 单向链表串联,包含执行指令偏移 f_lasti 以及命名空间字典 f_globals 和 f_locals。 (0:00)
- 基于 f_trace、f_trace_lines 和 f_trace_opcodes 的回调机制,构成了 PDB 调试器与 Coverage 等覆盖率工具的核心运行基石。 (5:00)
- Frame 赋予了 Python 极高的运行时透明度,但也导致函数调用带来显著的开销,过度拆分极短函数容易成为性能瓶颈。 (8:30)
Detailed notes
## Background 该视频由技术博主“码农高天”于 2024 年 3 月 30 日发布在 YouTube 平台上。视频简介指出,Python 的所有程序本质上都运行在一个个 frame(帧)之中,作者希望通过本期内容帮助大家深入了解这一鲜为人知却至关重要的 Python 底层运行机制。
## TL;DR 视频深入剖析了 Python 运行时的骨架——frame(帧)的核心工作机制,并明确了其与不可变 CodeObject 的本质区别。Frame 记录了函数调用栈的动态状态,包含局部变量、全局命名空间、指令指针以及用于调试分析的 trace 回调体系。开发者不仅可以利用 frame 实现获取调用者名称、局部变量及源码行号等“黑魔法”反射技巧,也需要认识到其透明度所伴随的函数调用性能开销。
## Atmosphere & style 视频整体呈现出一种沉稳扎实、深入浅出的硬核硬派技术教学风格。高天讲解条理清晰、语调平缓自信,善于运用“程序大楼”等生动比喻将抽象的 CPython 底层结构拆解得通俗易懂。观看过程中既能体会到底层原理被层层剥开的豁然开朗,也能在展示反射“黑魔法”时感受到探索编程底层的极客乐趣与严谨思考。
## Bulletin - [00:00](https://www.youtube.com/watch?v=7y4iz8Pjews&t=0s) Python 代码编译生成的 CodeObject 是不可变的静态元信息,而每一次函数调用都会生成独立的 frame 来记录动态的运行时状态与局部变量。 - [00:00](https://www.youtube.com/watch?v=7y4iz8Pjews&t=0s) Frame 逻辑上构成调用栈,底层通过 f_back 单向链表串联,包含执行指令偏移 f_lasti 以及命名空间字典 f_globals 和 f_locals。 - [05:00](https://www.youtube.com/watch?v=7y4iz8Pjews&t=300s) 基于 f_trace、f_trace_lines 和 f_trace_opcodes 的回调机制,构成了 PDB 调试器与 Coverage 等覆盖率工具的核心运行基石。 - [08:30](https://www.youtube.com/watch?v=7y4iz8Pjews&t=510s) Frame 赋予了 Python 极高的运行时透明度,但也导致函数调用带来显著的开销,过度拆分极短函数容易成为性能瓶颈。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=7y4iz8Pjews&t=0s) CodeObject 是编译期生成的不可变对象,仅包含静态元信息,无法记录当前运行时的变量值。 - [00:00](https://www.youtube.com/watch?v=7y4iz8Pjews&t=0s) Frame 是运行时的状态容器,每次函数调用都会分配独立的 frame 来维护当前的局部变量与执行上下文。 - [00:00](https://www.youtube.com/watch?v=7y4iz8Pjews&t=0s) Python 程序的运行流程类似于调用栈,进入代码块或调用函数即进入新一层 frame,返回时则退回上一层。 - [00:00](https://www.youtube.com/watch?v=7y4iz8Pjews&t=0s) 在代码中可以通过 inspect.currentframe() 或 sys._getframe() 获取当前的 frame 对象,推荐使用更规范的 inspect 模块。 - [00:00](https://www.youtube.com/watch?v=7y4iz8Pjews&t=0s) Frame 的底层结构通过 f_back 指针连接上一层 frame,形成表示调用关系的单向链表栈。 - [05:00](https://www.youtube.com/watch?v=7y4iz8Pjews&t=300s) f_lasti 扮演程序计数器(PC)的角色,记录当前字节码偏移量,保证函数调用返回后能准确回到原处继续执行。 - [05:00](https://www.youtube.com/watch?v=7y4iz8Pjews&t=300s) f_lineno 记录当前执行的源码行号,主要用于向开发者提供可视化的定位与排查信息。 - [05:00](https://www.youtube.com/watch?v=7y4iz8Pjews&t=300s) 调试器(PDB)和测试覆盖率工具(Coverage)依托 f_trace 及相关行与字节码触发标记实现单步跟踪与回调。 - [05:00](https://www.youtube.com/watch?v=7y4iz8Pjews&t=300s) 通过 frame.f_back 可以跨栈层读取调用者的函数名、文件名、行号以及调用者作用域中的局部变量。 - [08:30](https://www.youtube.com/watch?v=7y4iz8Pjews&t=510s) 高度透明的反射机制使开发者能随时掌握完整运行状态,但也导致了函数调用的固有运行时开销。 - [08:30](https://www.youtube.com/watch?v=7y4iz8Pjews&t=510s) 尽管 CPython 尽可能复用 frame 以避免频繁内存分配,但过度将代码拆分成极短函数仍会带来不可忽视的调用瓶颈。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=7y4iz8Pjews&t=0s) 运行时状态的容器:Frame 与 CodeObject 的区别
在 Python 底层运行机制中,存在一个非常重要、非常基础但日常开发很少直接打交道的核心概念——frame(中文常被翻译为“帧”,但码农高天直言“我不是特别喜欢这个翻译,所以我们就管它叫 frame”)。在理解 frame 之前,必须先厘清它与 `CodeObject` 的关系。所有 Python 代码在编译期都会先被编译成 `CodeObject`,但 `CodeObject` 本身是不可变(immutable)的对象,编译完成后内容就固定了。虽然运行时需要它,但程序绝不能单纯依赖它,因为程序执行需要记录大量动态的运行时状态。例如,`CodeObject` 记录了某个函数需要使用局部变量 A、B、C,但它根本没有空间去记录当前运行时 A、B、C 的具体数值是多少;“而记录当前运行时状态的责任,就落在了 frame 身上。”
每一个函数只会编译出一个唯一的 `CodeObject`,保存大部分静态元信息;但当函数在运行时被多次调用、递归调用或处于不同上下文中时,每一次调用都会动态生成一个独立的 frame。这个 frame 仅保存该次调用中变化的部分,比如局部变量当前的值。高天形象地打了个比方:“你可以把 Python 的运行时想象成一座大楼,在进入程序的时候从一层开始,然后每有一段新的 code block 就再往上走一层,每一层都记录着当前这一层的状态。”最典型的“换层数”或切换 frame 的场景就是函数的调用与返回。在简化的模型中,每一次函数调用就会新建一个 frame,函数的代码就在此 frame 内执行;一旦调用另一个函数,就进入下一个 frame;当函数返回,就退回上一层 frame。它的本质就是一个栈(call stack)结构。
获取当前的 frame 对象非常简单。在 Python 代码中,通常可以使用 `inspect.currentframe()`,或者使用 `sys._getframe()`。实际上,`inspect.currentframe()` 在成功时调用的就是 `sys._getframe()`,只是两者在获取失败时的行为略有不同。考虑到前缀带下划线的函数属于内部接口,因此更推荐使用 `inspect` 模块封装好的函数。通过类似 `objprint` 的工具打印 frame 对象,会发现其所有属性均以 `f_` 开头,这正好对应了 `CodeObject` 中以 `co_` 开头的属性命名惯例。
```mermaid flowchart TD accTitle: Python 函数调用栈与 Frame 结构 accDescr: 展示函数逐层调用时 Frame 通过 f_back 形成链表栈的引用关系
subgraph S3["Frame 3 (当前正在执行)"] F3["f_code: 目标函数字节码<br/>f_locals: 当前局部变量<br/>f_lasti: 当前指令偏移"] end
subgraph S2["Frame 2 (中间调用者)"] F2["f_code: 中间函数代码<br/>f_locals: 中间局部变量<br/>f_lasti: 等待返回的指令"] end
subgraph S1["Frame 1 (底层的调用入口)"] F1["f_code: 模块/主程序代码<br/>f_globals: 全局命名空间"] end
F3 -->|"f_back"| F2 F2 -->|"f_back"| F1 ```
- `f_back`:指向上一层 frame(即当前函数的调用者对应的 frame)。虽然逻辑上是调用栈,但底层实现上是通过单向链表由 `f_back` 串联起来的。 - `f_builtins`:当前 frame 下对应的内置函数命名空间字典。 - `f_code`:当前 frame 对应的 `CodeObject`,包含编译期生成的关键信息,例如字节码、变量名列表等。 - `f_globals` 与 `f_locals`:当前函数视角下的全局变量字典与局部变量字典。当在函数内执行 `A = 1` 时,Python 正是通过 `f_locals` 记录下了变量 A 变成了 1(虽然底层实现是利用类数组的快速局部变量机制存储,但暴露出来的视图是字典)。
---
[05:00](https://www.youtube.com/watch?v=7y4iz8Pjews&t=300s) 指令指针、Trace 机制与 Frame 的“黑魔法”
除了变量字典,frame 中还维护着极其关键的执行控制属性。首先是 `f_lasti`(last instruction),在汇编语言概念中等同于程序计数器(Program Counter)。它记录了当前正在运行哪一个字节码指令。当函数 A 调用函数 B 时,B 返回后 A 能够准确回到原来的位置继续往下跑,依靠的正是 A 自己的 frame 中保存的 `f_lasti`。另一个属性是 `f_lineno`(line number),记录当前正在执行第几行源码,这个属性主要用于面向人类排查和定位代码。
随后的三个属性则构成了 Python 调试和追踪的核心基石:`f_trace`、`f_trace_lines` 和 `f_trace_opcodes`。Python 允许在每运行一行源码或者一个字节码指令后触发一个回调函数。高天解释道:“Python 里的 debugger 就是通过这个机制来完成的,我们比较熟知的 PDB 就是通过它来完成的,包括 Python 的一些工具,比如说 coverage 我们非常常用,也是通过这个机制来完成的。”如果通过 `sys.settrace()` 设置了追踪函数,`f_trace` 就不再为 `None`,而两个布尔值标记则决定是逐行触发(`f_trace_lines`)还是逐个字节码指令触发(`f_trace_opcodes`)。
正因为 frame 的存在,开发者可以在 Python 运行时的任意位置获取整个调用栈以及栈中每一帧的全部上下文状态,这直接催生了许多强大的“黑魔法”技巧: - **查看调用者的名字**:通过当前 frame 获取 `frame.f_back.f_code.co_name`。例如函数 F 被函数 G 调用,在 F 内部就可以直接打印出调用者的名字 `"g"`。 - **窥探调用者的局部变量**:通过 `frame.f_back.f_locals`,即使在被调用的子函数内部,也能实时读取调用者设置的局部变量(例如检测到 G 函数在调用 F 之前设置了 `A = 3, B = 4`)。 - **定位调用发生的源码位置**:针对通用的工具函数(utility function),在不知道是谁从何处调用的情况下,可以通过 `frame.f_back.f_code.co_filename` 和 `frame.f_back.f_lineno`,准确定位被调用的文件名和具体行号(例如准确输出是在该文件的第 11 行发起了调用)。
---
[08:30](https://www.youtube.com/watch?v=7y4iz8Pjews&t=510s) 机制的双刃剑:极高的透明度与函数调用开销
frame 机制赋予了 Python 极高的透明度。开发者可以在程序中的任何一个地方拿到全部的调用栈以及每一个 frame 的所有状态,高天评价说:“也就是说,你几乎可以随时随刻的掌握整个 Python 运行的状态。对于 debug 来说,或者对于某一些喜欢黑魔法的人来说,这绝对是一个好事。”
然而,这种强大的反射与状态监控能力是有代价的。每一个函数调用都要维护 frame,这不可避免地带来了明显的运行时开销(overhead)。尽管在 CPython 的底层实现中做过针对性优化——并不需要每次函数调用都真正地重新 `new` 一个全新的 frame 对象,而是会尽可能复用之前的 frame,但其内部状态管理的成本依然显著。这也是为什么在 Python 中如果将代码拆分成大量过于短小的函数时,函数调用的开销本身往往会成为整个程序最耗时的瓶颈。了解 frame 的机制,不仅有助于掌握 Python 底层的执行流程,也能让人在编写高性能 Python 代码时更有针对性地权衡代码抽象与调用开销。
## Notable quotes > [00:00](https://www.youtube.com/watch?v=7y4iz8Pjews&t=0s) 你可以把 Python 的运行时想象成一座大楼,在进入程序的时候从一层开始,然后每有一段新的 code block 就再往上走一层,每一层都记录着当前这一层的状态。 — 码农高天
> [08:30](https://www.youtube.com/watch?v=7y4iz8Pjews&t=510s) 你几乎可以随时随刻的掌握整个 Python 运行的状态。对于 debug 来说,或者对于某一些喜欢黑魔法的人来说,这绝对是一个好事。 — 码农高天
## Mentioned Python, CPython, Frame, CodeObject, PDB, coverage, inspect, sys, objprint, 码农高天
## Video description 这期视频聊一下frame,也就是帧。python的所有程序其实都是运行在一个个frame里的,只是对于这个底层机制,很多人并不熟悉。希望通过这期视频,大家可以了解一些python底层运行的机制!
[Source](https://www.youtube.com/watch?v=7y4iz8Pjews)