CPython核心开发者提交PEP 667改动争议交由指导委员会仲裁

我给Python写屎山【PEP 667的尾声】 — 码农高天

码农高天

由于向后兼容性与借用引用限制逼迫系统在段错误、内存暴涨及行为割裂间权衡,官方唯有标记并淘汰老旧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)

中文简报

CPython核心开发者将PEP 667改动争议提交指导委员会仲裁 由于向后兼容性与借用引用限制逼迫系统在段错误、内存暴涨及行为割裂间权衡,官方唯有标记并淘汰老旧API才能终结遗留代码问题。 在Python 3.13推进PEP 667的收尾过程中,开发团队试图彻底纠正旧版locals()共享底层字典的异常行为,将其改为返回独立快照,但该改动在与既有C API对齐时遇到了严峻的向后兼容难题。由于PyEval_GetLocals()历史上承诺返回借用引用(borrowed reference),直接返回新对象会导致旧代码内存泄漏或产生悬空指针,团队陷入了方案一(覆盖单槽缓存可能引发段错误)、方案二(列表暂存导致长循环内存暴涨)与方案三(C API退回旧行为导致两端不一致)的三难困境。最终核心开发者无法达成一致,已将决策提交给Python指导委员会(SC)投票裁决,并计划通过未来彻底弃用借用引用API来从长远化解历史包袱。 PEP 667在Python 3.13中将locals()的语义改为每次调用均返回独立的局部变量快照字典,彻底修复了此前多次调用共享并原地修改同一字典的反直觉历史机制。 (0:00) 对应C API PyEval_GetLocals()因历史上承诺返回借用引用,直接返回快照会破坏兼容性或触发野指针,迫使核心团队在三种各有缺陷的妥协方案中进行抉择。 (5:00) 由于单值缓存(潜在段错误)、列表追加(长循环内存泄漏)与回滚3.12行为(Python与C API语义割裂)均无完美解,该争议最终提交Python指导委员会(SC)投票表决。 (10:35) CPython团队处理向后兼容历史包袱的长远策略是逐步废弃所有返回借用引用的老旧API,避免过渡期引入的妥协代码长期固化并造成维护困境。 (12:30)

Reading summary

在为 PEP 667 进行收尾工作时,Python 核心开发者高天遇到了 C API 历史兼容性带来的架构困境,展示了大型基础软件工程中因向后兼容限制而不得不在多个缺陷方案中进行妥协的现实。 Python 旧版 locals 的荒谬机制与 PEP 667 改革 (0:00) 在 Python 3.12 之前,函数局部变量为了读写效率实际存储在底层的 C 数组中。每次调用 locals() 时,CPython 才会动态拼装一个字典,并将其强引用缓存在当前执行帧(frame)的 f_locals 字段中。这种设计导致多次调用 locals() 返回的实际上是内存中同一个被原地更新的字典对象,甚至会出现修改局部变量后再次调用函数,先前已赋值的外部变量内容跟着被隐式篡改的荒谬行为。为了彻底清除这一历史包袱,PEP 667 规定自 Python 3.13 起,locals() 每次调用都必须返回一个仅记录当前调用瞬间状态的独立快照(snapshot)。 C API 借用引用约定引发的兼容性困境 (5:00) 当尝试将快照机制同步到 C API 函数 PyEval_GetLocals() 时,历史契约引发了严重冲突。该 API 在历史上承诺返回借用引用(borrowed reference),即调用方无需也不应调用 Py_DECREF,其前提是该对象必须在别处存在强引用以防被销毁。若直接将新生成的快照字典作为 new reference 返回,数百万用户未修改的旧代码将产生内存泄漏;若在函数返回前对快照字典执行减引用,对象会瞬间被就地释放,直接对外抛出非法的悬空指针。这种严格的向后兼容限制,封死了直接改造返回类型的可能。 三种缺陷方案的权衡与分歧 (8:35) 为了维持借用引用的有效性,核心开发者们权衡了三种各有致命硬伤的方案:方案一由高天提出,在 frame 结构中新增单个缓存槽位(f_locals_cache)持有强引用并返回借用指针,但连续两次调用该 API 会导致前一次的指针悬空并可能引发段错误(Segfault);方案二在 frame 上挂载一个列表保存所有生成的快照,虽然绝对内存安全,但在大循环中调用会导致内存占用无限膨胀;方案三由另一位核心开发者提出,让 C API 彻底回退至 3.12 的旧逻辑,虽无崩溃和内存膨胀风险,却导致 C API 与 Python 层内置函数行为割裂。由于方案各存缺陷且无法达成共识,最终只能提交给指导委员会(Steering Council)仲裁。 大型工程的技术妥协与长期演进路径 (12:30) 高天指出,在 CPython 这样拥有巨大历史生态的基础工程中,现实往往不存在完美的架构解法,工程师常常被迫在妥协中选择代价最小的方案。为了避免代码库在妥协累加下彻底沦为不可维护的“屎山”,核心开发者达成的长远共识是建立清晰的淘汰周期:官方架构正在有计划地逐步弃用所有返回借用引用的旧式 API。无论眼下在 Python 3.13 中采用哪种过渡折中方案,最终都会通过弃用标记引导下游生态迁移到现代引用计数接口,从根本上终结技术债务的蔓延。 该案体现了工业级基础软件在平衡接口语义一致性、内存安全以及向下兼容生态时的固有张力,方案最终的选择交由指导委员会裁决,而彻底的解法则有赖于未来弃用老旧借用引用接口的长周期演进。

Source quotes

[8:35] 高天: 我们没有办法在 API 级别避免用户 segfault 自己的代码

Original transcript

[0:00] 今天给大家分享一个我在给PIP667收尾的时候遇到的问题这个问题呢目前为止啊都没有定论我们甚至已经把所有的可能性放到了SC那边啊让他们去投票了感兴趣的朋友呢可以来这个issue看一下具体的细节我呢今天给大家简单讲一下这个问题让大家感受一下为什么我们说在工程中经常是没有完美解的顺便调查一下啊在这几个都不好的解里面大家更喜欢哪一个首先做一个背景回顾啊我们今天的讨论和locals这个函数有关这个函数会返回当前frame的局部变量和他们的值组成的字典比如在f函数里我们让x等于1然后把locals存到a里再打印a可以看到我们打印出来的a就是x是1非常简单对吧好我们开始上强度在a等于locals之后我们加一个x等于2再打印a请问a是什么不知道大家能不能猜对啊A还是X1我们在X等于2之后再调用一下local这个函数注意我并没有把它复制给A接下来我和之前一样再打印A神奇的事情发生了X变成2了并且这个字典里多了A这个local variable我不知道有多少人了解这件事背后的原理啊,如果你知道啊,那说明你对Python的了解程度还是非常高的我们简单讲一下原理啊,在之前的视频中我们提到过在一个函数的frame里,局部变量并不是被直接保存在字典里的为了使读写局部变量的效率变得更高实际上这些局部变量是被保存在一个C array里的而当我们调用locals也就是试图拿到这个局部变量的字典的时候Python内部实际上要完成一个把这些局部变量变成一个字典的过程它要读取这些局部变量的值然后把这些局部变量给拼成一个字典而拼成的这个字典是被缓存在这个frame的flocals里的大家可以看从1216行开始的这一段代码如果flocals里面现在还是空的那就建立一个新的字典后面的部分就是读取这些局部变量值然后把这些局部变量的值c到字典里这个时候我们再看回刚才的程序我们在做a等于locals的时候他做了一下这个操作把当前的局部变量x等于1放到了这个字典里但是当我们再做x等于2的时候这个字典并没有被更新我们只是更新了局部变量所以如果这个时候我们立刻printA我们打印出来的那个字典里面依然只有x是1这个信息而当我们再运行一下locals之后尽管我们没有把这个值复值到A但是它实际上是更新了这个frame而保存的那个字点所以A也变了于是我们打印出来的这个A就更新了事实上如果我们把第二个值给保存到B我们可以发现A跟B指向的是同一个字点也就是那个被缓存在frame上的Flocal也就是说你保存着local这个字点的A在局部变量被修改之后它没有被修改但是在你再调用了一次local之后他就被改了如果你觉得这个结果非常荒谬恭喜你你已经有了core dive的思维了这个极其荒谬的结果就是PIP667修改的一个部分在最新的3.13版本里由于有了PIP667的实现每一个local参数返回的都是当前状态下的一个snapshot也就是这个字典只记录这个local参数被调用的那一瞬间的情况我们可以看一下最新版本的python的运行结果这里a跟b就不再是同一个字典了它们是两个不同的字典同时a里面是x等于1b里面是x等于2一切都看起来很美好对吧这个Local谈数呢有一个CAPIPIEVAL GET LOCALS通过这个API你可以在C里面实现LocalS这个功能在我处理它的时候我想呢这个东西很简单对吧只要把我做出来的那个字典直接返回回来就好了然而问题呢在一个不经意的地方出现了那在最开始的实现包括现在3.13的这个Beta版本里我就是这么干的但是啊没多久就有人发现了一个问题什么问题呢就是这个PIEVL GET LOCALS API它返回的是一个BORROWED REFERENCE这里需要大家对引用基数机制有一个基本的了解所谓返回一个BORROWED REFERENCE就是它不产生一个新的引用使用这个API的用户不需要再去释放引用在某一些情况下这种反应 [5:00] 方式在效率上是会更高的因为如果你这个对象在使用的过程中并不会被销毁你给他加个引用我用完了再释放引用这些事情都是有性能上的损耗的注意这个前提啊这个对象本身不能被销毁换言之这个对象必须在某一个地方有一个强引用在3.12之前的版本中这件事非常好理解因为这个对象是放在frameflocal上的而frame Flocal是一个强引用所以只要这个frame还在这个local字点它就一直在同时每一次local返回这是同样的一个字点就是这个Flocal它就可以无限的返回这个borrowed reference而在3.13 beta版里面我这里想当然的返回了一个强引用因为记不记得每一次local参数我都会建立一个全新的字点这个字点本身它不再被复用了我当时想我建立完了这个字典跟着它那个引用直接返回回去不就完了吗于是这里就出问题了本来这个API应该返回一个borrowed reference我返回一个new reference你可能会觉得啊这有什么了不起的你把文档改一下不就得了吗如果是一个非常小的项目当然可以对吧但是对于python这种有数百万用户的语言来说这种级别的API改动意味着所有使用到这个API的人都需要修改他们的代码原来你拿到的这个引用你是不用释放的现在你需要释放了也就意味着如果你不修改使用这个API的代码你的程序就会内存泄漏因为有一个强引诱你没有释放也就是说在这里返回一个强引诱或者说是new reference是一个不可接受的事情这个API它必须要返回一个borrowed reference如果你想挑战自己请在这里暂停一下想一想你会用什么方案来解决这个问题那你的第一反应有可能是不就是把一个new reference变成borrowed reference吗我在返回之前我直接把这个引用释放了不就完事了吗这样用户就不用来释放引用了对吧想法很好这个操作确实是把一个new reference变成了borrowed reference可问题是在这个时候你释放的那个reference是only reference这个引用是这个对象的唯一引用这个对象是一个刚刚被建立的字典它唯一的引用就在这里当你释放这个引用的时候这个字典就直接被释放掉了你返回那个指针本身就不合法了它指向那个对象就没了这里你就会感受到我们这个实现跟之前实现的核心区别之前的那个实现它返回那个字典有一个确定的引用活在frame上而我们现在的这个实现返回的字典没有引用活在frame上它唯一的引用就在这里我如果在这里把引用释放了对象就没了所以这个方案是行不通的那从这里接着往下想是不是我保持一个强引用在这个frame上我就可以在这里把这个引用释放了呢如果你能想到这里非常好你已经向着正确的目标前进了那接下来啊我们就要开始正式介绍我们提到的三坨石也就是三个明显不怎么好但无可奈何的方案1233者是我的实现啊也是我觉得相对好一点的方案你不就是需要一个强引用吗我给你一个强引用不就完了在拿到这个字典之后我把这个字典先保存到这个frame上面的一个变量里和刚才那个flocals的机制非常相似至于为什么不能直接用flocals这里有个细节问题啊不展开讲了大家听不懂我这里呢就是当用户每一次调用这个api的时候产生的这个新字典我给放到这个flocals cache上然后返回一个borrowed reference这样用户就不用去释放这个引用了当这个frame消失的时候这个引用自然的会被释放然后这个字典就会被释放看起来很美好对吧不知道大家能不能想到这个实践的问题如果你细琢磨一下你会发现由于我只有一个变量来保存cache所以当我第二次调用Pievel getlocals的时候第一次保存的那个字典就会被直接释放换言之如果我在C层面做相似的事情拿了一个local再拿一个local这个时候第一个local保存的变量就会指向一个已经被释放的内存这是一个很明确的问题对吧但是我对这个实现的理解是首先当用户已经开始写C的时候他有无数种方式 [10:00] 可以让自己的程序塞个法的我们没有办法在API级别避免用户sagfault自己的代码如果用户想保留之前拿到的那个字典最稳妥的方法是自己加一下饮用也就是在做完PiE-Well-Gate Locals之后自己Incraft一下这样才能保证那个东西不被释放第二在现有的程序里拿一个local再拿一个local然后再去Access第一个Locos这样的程序应该非常少所以这个情况的影响不是特别大这是我的argument 不代表它是正确的就像我刚才说的啊三个方法都不太好我认为这个方法是挫子里的将军那接下来再介绍两个仇子我们刚才提到这里的问题所在就是当我第二次调用拍EVLGAD LOCALS的时候第一次保存的那个cash会被直接干掉有一个很直观的解决方案就出现了我这个flocals cache,我里面保存一个list不就完事了吗?我整个列表我产生一个字典往里塞一个产生一个字典往里塞一个我把每一个字典都强移用到这个列表上这样我可以保证返回的每一个字典他都有对应的强引用等这个frame结束的时候把它一释放就算拉倒这种实验方案确实能解决我刚才说的问题但是他也带来了另一个问题就是你每调用一下Pievel get Locals你都会建立一个直到这个frame被释放之前都不会被释放的字典如果我在一个很大的 full loop 里面做这件事那内存占用是不是非常可观那这个方案呢是我和另外一个CoreDev我们两个人公认的最不理想的方案当然我猜测肯定有很多我的观众朋友们觉得这个方案是最好的毕竟他是一个绝对正确的方案对吧大家完全可以保留自己的意见因为这个问题确实是没有标准解的好那第三个方案是什么呢第三个方案是另外一个CoreDev提出来的他说我们可以让PyEvalGetLocals这个API保持3.12版本的Locals behavior也就是说在3.13版本中如果一个用户使用locals他得到的依然是一个全新的字典这个字典只会反映当前局部变量的情况但是如果他用C API 调用 py eval get locals他就会回到3.12的状态他得到的永远是同一个字典并且得到的这个字典在下一次调用PIEVAL GET LOCAL的时候,还会被更新我个人相对没那么喜欢这个方案的原因是他会导致3.13版本中这个CAPI跟Python的函数不一致当然这个方案明显的优势就是它本身不再会导致SACFORT了同时他也不会导致内存使用的无线增加于是我们现在面临的问题就是这三坨要吃哪一个我们两个人都有自己的意见所以就把这件事上报给了SC 让SC拿主意讲这个故事呢其实最想告诉大家的是当你做一个比较大型的项目的时候为了兼容性你不得不做一些不那么理想的实现这件事有的时候是难以避免的那如果这件事难以避免,我们怎么尽可能避免所谓史山的堆积呢?我和另外一个CoreLab在这件事上有一点我们观念是一致的我们应该deprecate这个API也就是逐渐弃用这个API把这个API从cpython里面删掉实际上cpython确实在deprecate很多使用borrowed reference的API所以这个东西也不是孤立那通过这种方式无论我们现在临时的方案是怎么样的长得多么丑陋我们都会在未来逐渐把它淘汰掉从而避免史山的堆积那如果你已经看到这里了不妨也留个言啊说一说这三个方案里你相对更喜欢哪个方案看看你的选择和最终我们使用的实现是不是能保持一致好那这期影片这里喜欢大家记得点赞订阅别忘了一键再见了哟优优独播剧场——YoYo Television Series Exclusive