码农高天
循环引用迫使对象销毁依赖垃圾回收器介入,若GC在持锁临界区突发触发包含加锁逻辑的__del__函数将导致不可重入锁死锁,开发者应避免在析构函数中执行加锁或网络操作。
AI-assisted summary of 【python】自从用了它,我的程序动不动就死锁! — original source. Check the original for full context.
Source published:
Key ideas
- 在持有不可重入互斥锁的临界区内,大量创建对象可能越过 GC 阈值并触发循环引用对象的 `__del__`,若析构函数再次请求同一把锁,将引发自我死锁。 (1:14)
- 生产环境中第三方组件(如 gRPC)内部自带的线程锁同样容易受到 GC 析构时机的干扰,即便业务代码未显式加锁也可能导致死锁。 (3:22)
- 常见的技术误区是将 `del` 关键字等同于立即调用 `__del__`,实际上 `del` 仅减少引用计数,对象回收与析构时机完全由引用计数和分代垃圾回收器决定。 (3:22)
- 规避 `__del__` 锁竞争的临时方案是在临界区内使用上下文管理器包裹 `gc.disable()`,而根本解法是避免在析构函数中执行涉及加锁或 I/O 的操作,解耦资源生命周期与内存管理。 (3:22)
Detailed notes
## Background 该视频由技术博主“码农高天”于 2026 年 2 月 6 日发布在 YouTube 平台上。视频简介指出,本期内容围绕 Python 中危险的 `__del__` 析构函数展开,详细拆解它是如何在实际项目中引发程序死锁的,并为开发者提供针对性的规避方案。
## TL;DR 视频通过一个源自 Apache Spark 开源项目简化的最小复现案例,演示了仅因增大资源释放循环次数就导致程序死锁的诡异现象。高天深入剖析指出,当对象存在循环引用时,其销毁依赖于垃圾回收(GC),而 GC 会在不可控的时机被对象创建阈值触发;若在不可重入锁保护的临界区内突发 GC 并执行 `__del__`,且析构逻辑又去尝试获取同一把锁,就会酿成死锁。针对此问题,高天揭示了诸如 gRPC 内部持锁的工业界真实隐患,指出 `del` 关键字并不等于调用 `__del__` 的误区,并提出用上下文管理器临时禁用 GC 的妥协手段,以及彻底解耦资源与对象生命周期的架构规范。
## Atmosphere & style 视频整体呈现出一种沉稳扎实、抽丝剥茧的硬核极客探案风格。高天以轻松而略带调侃的口吻切入,用直观的代码演示构筑悬念,随后带领观众逐层深入 Python 底层运行时,现场破案。整场讲解节奏紧凑、直击痛点,既有资深工程师面对隐蔽 Bug 时的冷静拆解,又充满了告诫开发者警惕认知误区的幽默与诚恳。
## Bulletin - [01:14](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=74s) 在持有不可重入互斥锁的临界区内,大量创建对象可能越过 GC 阈值并触发循环引用对象的 `__del__`,若析构函数再次请求同一把锁,将引发自我死锁。 - [03:22](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=202s) 生产环境中第三方组件(如 gRPC)内部自带的线程锁同样容易受到 GC 析构时机的干扰,即便业务代码未显式加锁也可能导致死锁。 - [03:22](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=202s) 常见的技术误区是将 `del` 关键字等同于立即调用 `__del__`,实际上 `del` 仅减少引用计数,对象回收与析构时机完全由引用计数和分代垃圾回收器决定。 - [03:22](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=202s) 规避 `__del__` 锁竞争的临时方案是在临界区内使用上下文管理器包裹 `gc.disable()`,而根本解法是避免在析构函数中执行涉及加锁或 I/O 的操作,解耦资源生命周期与内存管理。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=0s) 案例源自 Apache Spark 相关开源项目的真实工作场景,涉及全局资源释放逻辑与互斥锁设计。 - [00:00](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=0s) 在资源释放逻辑中,仅将模拟 workload 的对象创建数量由 50 提高到 1000,程序便由正常退出转为稳定死锁。 - [01:14](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=74s) 循环引用的存在使得对象无法在离开作用域后被即时回收,必须等待分代垃圾回收器(GC)扫描介入。 - [01:14](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=74s) Python 垃圾回收器的触发时机取决于对象分配阈值,在外部时序上具有高度不可预测性。 - [01:14](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=74s) Python 默认提供的互斥锁是不可重入锁,在同一线程持锁期间再次请求该锁会导致死锁。 - [01:14](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=74s) 持锁临界区内产生的 workload 触发 GC,GC 执行 `__del__` 再次调用释放资源函数,造成不可重入锁自锁。 - [01:14](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=74s) 显式调用 `gc.collect()` 能百分之百复现该死锁问题,直接证实死锁由垃圾回收触发。 - [03:22](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=202s) 真实生产环境中,`__del__` 内部发起 gRPC 远程调用时会请求其内部维护的原生锁,同样会诱发难以排查的死锁。 - [03:22](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=202s) 开发者常误以为 `del x` 会立刻同步执行 `x.__del__()`,实际上它仅解除变量绑定并扣减一次引用计数。 - [03:22](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=202s) 在无法移除 `__del__` 的过渡场景中,可在关键加锁区间通过 `gc.disable()` 与 `gc.enable()` 暂时屏蔽垃圾回收以规避死锁。 - [03:22](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=202s) 编写 `__del__` 的最高原则是保持纯粹和极简,永远不要在析构函数中执行任何可能引发加锁的操作。 - [03:22](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=202s) 系统架构层面应实现资源生命周期与 Python 对象生命周期的解耦,避免将关键资源释放绑定在不可控的析构回调上。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=0s) 一个看似正常的资源释放逻辑与神秘死锁
高天首先提到自己已经很久没有录制技术视频了,这次想聊一个在工作中遇到的非常有趣的真实问题。这个案例抽象简化自 Apache Spark 相关的开源项目代码。在日常工程中,经常会遇到管理全局资源的场景:程序在最后阶段需要释放这些全局资源,而且考虑到多线程环境,代码中特意设计了一个互斥锁(Lock)。每次调用释放函数时,程序都会先获取这把锁,接着判断资源是否已经被释放过,如果已经释放完毕就直接返回;如果尚未释放,则在锁的保护下进行状态更新和实际的资源释放。由于判断和赋值都在锁的临界区内,按常理并不存在并发竞争条件。为了模拟实际的释放过程,代码在首次释放时设计了一小段生成对象的 workload 来假装正在释放资源。
紧接着,代码中定义了一个极其简单的类 `A`,里面包含一个看似藏有玄机的 `__init__` 函数以及一个负责在对象销毁时执行的析构函数 `__del__`。此外还有一个函数 `f`,其内部实例化了类 `A` 的对象;主程序流程就是先调用函数 `f`,随后在程序结尾处统一释放资源。高天给观众留出时间观察代码并思考是否存在漏洞,随后进行了演示:当释放资源中的 workload 循环次数设为 50 时,程序运行完全顺畅、毫无异常;然而,仅仅是将循环次数从 50 改为 1000 以增加一些释放负担后,程序再次运行便直接卡死。通过 `Ctrl+C` 强行中断程序,可以看到调用栈确实被阻塞死锁在获取锁的那一行代码上。更诡异的是,只要将第 18 行那句看起来像“咒语”的代码注释掉,程序又能立刻恢复正常运行。高天笑称:“如果讲到这里能大概猜到发生了什么,你就可以给自己的 Python 水平打 60 分了。”
- **资源管理设计**:通过互斥锁保护资源释放状态,防止多线程重复释放或出现竞争条件。 - **诡异现象**:仅仅将释放阶段模拟 workload 的对象创建数量从 50 提升到 1000,程序便由正常退出变为死锁卡死。 - **关键诱因**:注释掉第 18 行看似奇怪的代码后死锁立即消失,暗示该行语句改变了对象的生命周期管理方式。
---
[01:14](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=74s) 破案:循环引用、GC 触发与不可重入锁
高天深入剖析了第 18 行代码背后的真正玄机。这行语句的核心作用其实非常纯粹:故意构造循环引用。在 Python 中,绝大多数对象的销毁依靠引用计数机制即时完成;但一旦引入了循环引用,对象即便在函数 `f` 执行完毕、失去了全部外部引用之后,由于内部相互引用的存在,其引用计数依然不为零,因此无法被立即回收。这就迫使该对象必须等待 Python 的分代垃圾回收器(Garbage Collector)介入才能被清理。而对于广大 Python 开发者来说,“最安全的想象是它可以在任何时刻被触发”。
具体到垃圾回收器的底层触发逻辑,GC 的运行时机与当前进程中新创建的对象数量(阈值)直接相关。当 workload 仅生成 50 个新对象时,尚未跨越垃圾回收的阈值线;但当数量增加到 1000 个时,GC 被强制触发。此时灾难发生:GC 在扫描回收无用对象时调用了类 `A` 实例的 `__del__` 函数,而该函数内部偏偏又去调用了释放资源的函数。由于外层逻辑此时正持有互斥锁在执行释放流程,而 Python 默认提供的互斥锁是不可重入锁(Non-reentrant Lock),因此在同一线程持有锁的情况下,嵌套的 `__del__` 流程试图再次获取同一把锁,瞬间构成了自我死锁。为了证明这一点,高天展示了无需设置 1000 次循环,只需显式调用 `gc.collect()` 强制触发垃圾回收,程序就会百分之百稳定地卡死在锁上。
```mermaid flowchart TD accTitle: Python __del__ 与不可重入锁死锁形成链路 accDescr: 描述外层持锁释放资源过程中产生新对象触发GC,导致__del__再次调用释放函数从而造成不可重入锁死锁的过程。
subgraph OuterFlow ["主释放流程"] A["调用 release_resource()"] --> B["获取 Lock (成功)"] B --> C["执行 workload (创建 1000 个新对象)"] end
subgraph GCFlow ["垃圾回收机制"] C -->|"对象数量超过阈值"| D["触发 Python GC"] D -->|"扫描清理循环引用对象 A"| E["调用 A.__del__()"] end
subgraph DeadlockNode ["二次调用导致死锁"] E --> F["__del__ 再次调用 release_resource()"] F --> G["试图获取同一把 Lock"] G -.->|"锁已被外层占用且不可重入"| H["程序永久死锁 (Deadlock)"] end ```
- **循环引用绕过引用计数**:对象失去外部作用域后仍存活,完全听凭 GC 调遣。 - **GC 触发时机的不确定性**:依赖于对象分配计数阈值,难以从外部时序上精确预测。 - **不可重入锁陷阱**:在持锁上下文期间触发了含有同一把锁请求的回调或析构,直接导致自己等待自己。 - 高天强调不可重入锁导致的问题:“我们在拿着这个锁的同时,由于内部的调用再去拿这个锁,就形成了死锁。”
---
[03:22](https://www.youtube.com/watch?v=xJSTbtE9nBo&t=202s) 真实案例剖析与防坑指南:如何规避 __del__ 陷阱
高天进一步分享了生产环境中的真实案情。在他们实际遭遇的 Spark 相关问题中,`__del__` 函数内部执行的并不是简单的自定义释放代码,而是一个 gRPC 远程调用。gRPC 在处理网络通信和通道管理时,其内部原生维护了自己的锁机制,甚至不需要开发者自己写锁;但正是因为在 GC 触发的 `__del__` 阶段发起了 gRPC 调用并试图拿锁,最终导致了隐蔽且难以排查的死锁。高天借此告诫大家,在使用 `__del__` 析构函数时必须极其慎重,甚至应当遵循“能不用就不用”的原则,因为开发者根本无法精准掌控它究竟会在哪一条指令执行时被触发。
针对部分开发者常有的认知误区,高天直言:“很多人会有天真的幻想对吧,通过 del 这个关键字来直接触发 `__del__` 这个函数。有这个想法的同学们,多看看我的视频,你们还有很大的进步空间。”因为 `del` 关键字仅仅是解除变量绑定并减少一次引用计数,绝非直接调用析构函数。如果业务场景确实苛刻到无法完全移除 `__del__`,高天建议了一种妥协方案:在进入需要拿锁的临界区代码之前,显式通过 `gc.disable()` 暂时关闭垃圾回收,待操作完成之后再恢复,最好将其封装成优雅的上下文管理器(Context Manager),确保临界区内绝不会突发任何不可控的 GC 行为。不过从系统架构设计的长远角度来看,最健康的做法依然是实现解耦:让 `__del__` 保持绝对简单,永远不要在里面执行任何可能涉及加锁的操作,让资源的生命周期不要与 Python 对象的生命周期强行绑定。高天总结道:“当你在写这个函数的时候,你就应该意识到这个函数是有可能在你代码的任何部分被调用的,你要先接受这个前提再去写这个函数。”
- **真实工业陷阱**:第三方库(如 gRPC)内部自带的线程锁同样会受到 GC 析构时机的背刺。 - **常见认知误区**:误将 `del x` 语句等同于立即同步执行 `x.__del__()`。 - **临时规避手段**:在关键加锁区间通过 Context Manager 禁用垃圾回收(`gc.disable()` / `gc.enable()`)。 - **根本治理原则**: 1. 避免在 `__del__` 中执行任何涉及锁、I/O 或复杂依赖的操作。 2. 解耦外部资源生命周期与 Python 内存对象生命周期,尽量不依赖析构函数自动释放关键资源。
## Mentioned Python, 高天, Apache Spark, gRPC
## Video description 这期视频给大家介绍一下危险的__del__,讲讲它是怎么在我们的项目中造成死锁的,以及你应该如何规避这种情况的发生。
[Source](https://www.youtube.com/watch?v=xJSTbtE9nBo)