码农高天
由于向后兼容性与借用引用限制逼迫系统在段错误、内存暴涨及行为割裂间权衡,官方唯有标记并淘汰老旧API才能终结屎山代码循环。
AI-assisted summary of 我给Python写屎山【PEP 667的尾声】 — original source. Check the original for full context.
Source published:
Key ideas
- PEP 667 在 Python 3.13 中将 locals() 的语义改为每次调用返回独立的局部变量快照字典,彻底修复了此前多次调用共享并原地修改同一字典的反直觉历史机制。 (0:00)
- 对应 C API PyEval_GetLocals() 因历史上承诺返回 borrowed reference,直接返回快照会导致破坏兼容性或触发野指针,迫使核心团队在三种各有缺陷的妥协方案中抉择。 (5:00)
- 由于单值缓存(潜在段错误)、列表追加(长循环内存泄漏)与回滚 3.12 行为(Python 与 C API 语义割裂)均无完美解,该争议最终提交 Python 指导委员会(SC)投票表决。 (10:35)
- CPython 团队处理向后兼容历史包袱的长远策略是逐步废弃所有返回 borrowed reference 的老旧 API,避免过渡期引入的妥协代码永久固化为不可维护的屎山。 (12:30)
Detailed notes
## Background 该视频由 YouTube 频道“码农高天”于 2024 年 6 月 8 日发布。视频简介说明,本期内容主要探讨作者近期参与 CPython 底层开发(PEP 667 尾声阶段)时遇到的工程困境,以及在面对三个各有缺陷的妥协方案时该如何进行抉择。
## TL;DR 在 Python 3.13 推进 PEP 667 的收尾过程中,开发团队试图彻底纠正旧版 locals() 共享底层字典的怪异行为,将其改为返回独立快照,但这一改动在对齐历史 C API 时遭遇了严重的向后兼容难题。由于 PyEval_GetLocals() 历史上承诺返回借用引用(borrowed reference),而直接返回新对象会导致老代码内存泄漏或悬空指针,团队陷入了方案一(覆盖单槽缓存可能引发段错误)、方案二(列表暂存导致长循环内存暴涨)与方案三(C API 退回旧行为造成两端不一致)的三难困境。最终核心开发者们无法达成完全一致,将决策提交给 Python 指导委员会(SC)投票表决,并计划通过未来彻底弃用借用引用 API 来从长远化解历史包袱。
## Atmosphere & style 视频展现出一种一线核心开发者特有的务实与坦率,语气平实幽默却充满技术张力,充满了在现实工业级项目中遭遇“理想撞上现实”的无奈与自嘲。通过白板式的图解与层层递进的代码逻辑拆解,观众能清晰感受到在高并发、数百万用户的语言生态下做底层改动时如履薄冰的谨慎感。整体观感像是在听一位资深工程师好友复盘技术架构评审会上的妥协历程,既有剖析“屎山代码”成因的深刻与通透,也带着开放讨论的亲和力。
## Bulletin - [00:00](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=0s) PEP 667 在 Python 3.13 中将 locals() 的语义改为每次调用返回独立的局部变量快照字典,彻底修复了此前多次调用共享并原地修改同一字典的反直觉历史机制。 - [05:00](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=300s) 对应 C API PyEval_GetLocals() 因历史上承诺返回 borrowed reference,直接返回快照会导致破坏兼容性或触发野指针,迫使核心团队在三种各有缺陷的妥协方案中抉择。 - [10:35](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=635s) 由于单值缓存(潜在段错误)、列表追加(长循环内存泄漏)与回滚 3.12 行为(Python 与 C API 语义割裂)均无完美解,该争议最终提交 Python 指导委员会(SC)投票表决。 - [12:30](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=750s) CPython 团队处理向后兼容历史包袱的长远策略是逐步废弃所有返回 borrowed reference 的老旧 API,避免过渡期引入的妥协代码永久固化为不可维护的屎山。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=0s) Python 3.12 及之前 locals() 内部将局部变量缓存在 frame 的 f_locals 字典中,多次调用会原地刷新同一字典对象,导致不同赋值变量相互影响。 - [00:00](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=0s) CPython 的局部变量在函数内部实际存储在底层的 C 数组中以保障读写性能,字典仅在显式调用 locals() 时才动态拼装生成。 - [00:00](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=0s) PEP 667 规定 Python 3.13 中的 locals() 必须返回当前状态的独立快照(snapshot),彻底终结历史缓存字典机制。 - [05:00](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=300s) C API PyEval_GetLocals() 承诺返回 borrowed reference,要求返回的指针在底层必须有强引用持有以防止被意外回收。 - [05:00](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=300s) 若直接改返回 new reference 会破坏数百万用户的向后兼容性并导致老代码内存泄漏;若直接减引用则会导致指针立即失效成为野指针。 - [08:35](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=515s) 方案一建议在 frame 挂载单值缓存 f_locals_cache,虽无长循环内存泄漏,但在 C 中连续调用两次会导致第一次返回的指针悬空并引发段错误。 - [08:35](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=515s) 高天为方案一辩护认为,C 开发者本身就应了解内存管理规范,若需跨调用持久保留对象本就应手动 Py_INCREF,且真实场景极少回溯前次调用指针。 - [10:35](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=635s) 方案二提议使用列表保留 frame 内生成的所有快照字典,虽然能彻底消除段错误风险,但在长循环中调用会导致内存无限膨胀。 - [10:35](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=635s) 方案三主张让 C API PyEval_GetLocals() 维持 3.12 旧行为,虽然兼顾了内存安全且不泄漏,但造成了 Python 层面与 C API 层面的语义割裂。 - [10:35](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=635s) 在面对“三坨都不好看的代码”且开发团队内部存在分歧时,CPython 采取将争议提交给指导委员会(Steering Council)仲裁的治理机制。 - [12:30](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=750s) 大型开源基础软件演进往往受制于庞大的生态兼容性,技术选型常常不是寻找完美解,而是在不同技术代价之间做工程妥协。 - [12:30](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=750s) 消解妥协性屎山代码的根本途径是设立明确的弃用周期,CPython 正系统性废弃所有借用引用 API,引导生态迁移至安全的现代引用计数接口。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=0s) Python 原有 locals() 的荒谬行为与 PEP 667 的改动
高天在为 PEP 667 进行尾声收尾工作时,遇到了一个至今没有完美答案的困境。为了让大家体会到“为什么在实际工程中经常是没有完美解的”,甚至迫使他们把所有可选方案提交给 Python 指导委员会(Steering Council,简称 SC)去投票表决,他先梳理了 `locals()` 函数的历史机制与背景。
在常规理解中,`locals()` 会返回当前 frame(执行帧)局部变量与其取值组成的字典。但在老版本 Python 中,它的内部行为十分反直觉:如果在一个函数内设置 `x = 1`,将 `locals()` 赋值给变量 `a` 并打印,`a` 确实是 `{'x': 1}`;接着将 `x` 修改为 `2`,并在没有重新赋值给 `a` 的情况下再次调用一次 `locals()`,此时重新打印 `a`,神奇的事情发生了——`a` 中的 `x` 变成了 `2`,并且字典里还凭空多出了局部变量 `a` 本身。
```mermaid flowchart TD accTitle: Python 3.12 之前 locals 的缓存机制 accDescr: 展示旧版 Python 局部变量与 frame f_locals 之间共享同一字典指针的过程
subgraph Frame["Frame 数据结构"] Arr["C Array: [x=2, a=...]"] FL["f_locals 缓存指针"] end
subgraph Dict["唯一的字典对象"] D["{'x': 2, 'a':...}"] end
Arr -->|"调用 locals() 时更新"| D FL -->|"强引用持有"| D A["变量 a"] --> D B["变量 b"] --> D ```
这背后的深层原因在于底层 CPython 的实现机制: - 函数 frame 内的局部变量为了保证高效读写,实际上是存放在一个底层 C 数组(C array)中的,而不是直接存在字典里。 - 每次在 Python 中调用 `locals()` 时,CPython 内部需要动态完成拼装过程:读取 C 数组中局部变量的值,建立一个字典,并将该字典缓存在当前 frame 的 `f_locals` 字段中。 - 当首次调用 `a = locals()` 时,Python 创建了该字典并将其绑定给 `frame->f_locals`;当局部变量修改为 `x = 2` 时,因为尚未再次触发同步,字典本身没有刷新;但一旦用户在后续再次运行 `locals()`,CPython 会重新读取局部变量并原地更新保存在 `f_locals` 上的那个字典。 - 此时如果把第二次调用的结果赋给 `b`,打印对比会发现 `a` 和 `b` 实际上指向内存中的同一个字典对象。
高天感叹道:“如果你觉得这个结果非常荒谬,恭喜你,你已经有了 Core Dev 的思维了。”为了彻底纠正这种历史包袱,PEP 667 在 Python 3.13 中做出了重大改动:每次调用 `locals()` 返回的都必须是当前状态下的一个快照(snapshot),只记录函数被调用的那一瞬间的状态。在 3.13 中,`a` 和 `b` 成为了两个完全独立的字典,分别保存 `x=1` 与 `x=2`。
---
[05:00](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=300s) C API 兼容性难题与 Borrowed Reference 陷阱
当高天尝试将这个改进同步到对应的 C API——`PyEval_GetLocals()` 时,问题随之爆发。在最开始的实现以及 Python 3.13 Beta 版中,他直觉地认为只要把新生成的局部变量快照字典直接返回出去即可。然而很快有人指出:`PyEval_GetLocals()` 在历史上承诺返回的是一个借用引用(borrowed reference)。
所谓 borrowed reference,意味着该 API 不会产生新的引用计数,调用它的用户用完即走,不需要、也不应该去显式调用 `Py_DECREF`。这种机制在特定场景下有性能优势,省去了加减引用的开销,但它的绝对前置条件是:**该对象必须在某个地方存在一个强引用保证它在使用期间绝不会被意外销毁**。
| 机制对比 | Python 3.12 之前 | 3.13 早期 Beta 实现 | |:--- |:--- |:--- | | **存储位置** | 持久缓存在 `frame->f_locals` | 动态生成的全新字典(Snapshot) | | **引用类型** | Borrowed Reference(由 frame 强引用持有) | New Reference(强引用) | | **调用方行为** | 不需要也不能释放引用 | 必须手动释放,否则发生内存泄漏 | | **兼容性影响** | 无破坏 | 破坏生态中数百万用户的历史旧代码 |
如果只是一个小型个人项目,直接修改 API 文档强制要求用户释放即可,但高天强调:“但是对于 Python 这种有数百万用户的语言来说,这种级别的 API 改动意味着所有使用到这个 API 的人都需要修改他们的代码。”直接返回 new reference 属于破坏性改动(Breaking Change),会导致未改动的老代码直接发生内存泄漏(Memory Leak),因而是完全不可接受的。
此时如果尝试在 C API 函数返回前直接减去这个唯一的新引用(将 new 变为 borrowed),结果会更具灾难性:因为这个快照字典是刚生成的,其引用计数为 1;一旦执行减引用,该字典瞬间被就地释放(deallocated),返回出去的指针变成了一个指向无效野内存的非法悬空指针(dangling pointer)。为了让指针合法,必须在底层为它寻找一个能维持强引用的宿主。
---
[08:35](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=515s) 方案一:引入单值缓存变量与悬空指针风险
为了解决强引用的挂载问题,高天提出了自己的方案(方案一),也是他认为当前“挫子里拔将军”的解法:既然需要一个强引用,就在当前 frame 结构上新增一个专门的缓存变量(`f_locals_cache`,机制类似于老旧的 `f_locals`)。
当用户每次调用 `PyEval_GetLocals()` 时: 1. 产生当前的局部变量快照新字典。 2. 将这个新字典赋给 frame 上的 `f_locals_cache` 进行强引用持有。 3. 对外返回借用引用(borrowed reference)。 4. 当整个 frame 生命周期终结并被销毁时,该缓存变量持有的引用会被自然释放,字典随之回收。
然而该方案存在一个明显的缺陷:因为 frame 结构里只有一个缓存槽位,如果用户在 C 代码中连续两次调用 `PyEval_GetLocals()`,第二次调用生成的新字典会立即覆盖并释放第一次缓存的字典:
```mermaid flowchart LR accTitle: 方案一覆盖缓存导致的悬空指针 accDescr: 展示方案一在第二次调用 C API 时导致第一次结果变为野指针
subgraph Frame["Frame"] Cache["f_locals_cache 槽位"] end
D1["Dict 1 (第一次调用)"] D2["Dict 2 (第二次调用)"]
Cache -.->|"覆盖后销毁"| D1 Cache ===>|"强引用持有"| D2 Ptr1["外部引用 1 (野指针 / 段错误)"] -.-> D1 Ptr2["外部引用 2 (有效)"] --> D2 ```
此时如果外部 C 代码在拿到第二次 locals 之后再去访问第一次保存的指针,就会读取到已被释放的废弃内存,从而引发程序崩溃(Segmentation Fault / 段错误)。
高天给出了自己的辩护理由(argument): - 开发者既然已经开始在底层写 C 语言代码,本身就有无数种方式让自己的程序产生段错误,“我们没有办法在 API 级别避免用户 segfault 自己的代码”。如果 C 开发者想要跨调用持久保留某个字典,最标准稳妥的做法本就是自己手动 `Py_INCREF`。 - 在真实世界的代码库中,极少有业务逻辑会连续调用两次 `PyEval_GetLocals()` 后再去回溯访问第一个结果。
---
[10:35](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=635s) 方案二与方案三的权衡抉择
面对方案一可能导致段错误的问题,社区与 Core Dev 们又讨论了另外两套候选方案,形成了三种互有硬伤的选择:
- **方案二:使用列表保存所有快照(全局强引用列表)** - **实现逻辑**:不在 frame 上放单值缓存,而是挂载一个列表(List)。每当 `PyEval_GetLocals()` 生成一个新字典,就将其强引用追加到列表中,直到 frame 退出时统一销毁。 - **核心缺陷**:彻底杜绝了段错误风险,保证了绝对的内存安全,但在一个很大的 `for` 循环内部调用该 API 时,每次循环都会凭空堆积一个无法提前释放的新字典,导致内存占用无节制飙升,引发严重隐蔽的内存膨胀(Memory Bloat)。 - **评价**:高天与另一位核心开发者一致认为这是最不理想的方案,尽管在逻辑纯粹度上它看似“绝对正确”。
- **方案三:让 C API 回退并保持 Python 3.12 的旧行为** - **实现逻辑**:由另一位 Core Dev 提出。在 Python 3.13 中,Python 层面的 `locals()` 保持快照语义不变;但 C API `PyEval_GetLocals()` 彻底回滚到 3.12 时代的老逻辑,每次始终返回同一个在 frame 上维持的旧字典,并在后续调用时原地更新。 - **核心缺陷**:导致 Python 脚本层的 `locals()` 与 C API 层的 `PyEval_GetLocals()` 行为出现割裂与不一致。 - **核心优势**:既不会导致段错误(段错误为 0),也不会在长循环中导致内存无限泄露。
| 方案 | 核心做法 | 致命缺陷 | 核心优势 | |:--- |:--- |:--- |:--- | | **方案一(高天)** | Frame 单槽位覆盖缓存 | 连续调用后旧指针悬空,可能导致 Segfault | 行为贴近 3.13 规范,无长循环内存泄漏 | | **方案二** | Frame 挂载 List 保存所有历史快照 | 循环内调用导致内存占用无限飙升 | 内存安全,杜绝指针悬空 | | **方案三(另一 Core Dev)** | C API 回退保持 3.12 旧版更新逻辑 | C API 与 Python 层内置函数行为不一致 | 既不崩溃也不会无限消耗内存 |
面对这“三坨都不好看的代码”,开发者之间无法达成完全一致,最终将决策提交给了 Steering Council 仲裁。
---
[12:30](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=750s) 大型工程的妥协与“屎山”消解之道
高天总结了参与像 CPython 这种庞大工程时的切身体会:在软件工程演进中,面对巨大的历史生态和向后兼容性(Backward Compatibility)束缚,很多时候根本没有完美的优雅方案,工程师不得不去编写妥协性的代码。
关于如何在不得不引入妥协代码的前提下避免项目逐渐演变成无法维护的“屎山”,核心开发者们达成了一个明确共识:**逐步弃用并彻底剔除老旧 API**。 - 当前 CPython 官方架构正在有计划地弃用(deprecate)并最终清理所有返回 borrowed reference 的老旧 API。 - 无论眼下在 Python 3.13 中采用上述哪种丑陋的临时过渡方案,未来的方向都是给 `PyEval_GetLocals()` 贴上弃用标记,引导下游生态迁移到更合理的现代引用计数 API 上。 - 只有通过建立这种明确的生命周期淘汰路径,才能在包容现实妥协的同时,从长远维度上阻止“屎山”的永久堆积。最后高天也邀请观众在讨论区留下自己的选择,看看大众视角更倾向于承受哪一种技术代价。
## Notable quotes > [08:35](https://www.youtube.com/watch?v=LG-VhiWOKG8&t=515s) 我们没有办法在 API 级别避免用户 segfault 自己的代码 — 高天
## Mentioned 码农高天, Python, CPython, PEP 667, Python Steering Council, PyEval_GetLocals(), locals(), C 语言
## Video description 这期视频聊一下最近给CPython做开发遇到的一个困境,当然一个问题没有好的解决方案的时候怎么办,面对三个不理想的方案, 应该怎么选呢?
[Source](https://www.youtube.com/watch?v=LG-VhiWOKG8)