Python垃圾回收突发调用析构函数 嵌套互斥锁致程序死锁

【python】自从用了它,我的程序动不动就死锁! — 码农高天

码农高天

循环引用迫使对象销毁依赖垃圾回收器介入,若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)

中文简报

Python垃圾回收突发调用析构函数 嵌套互斥锁致程序死锁 循环引用迫使对象销毁依赖垃圾回收器介入,若GC在持锁临界区突发触发包含加锁逻辑的__del__函数将导致不可重入锁死锁,开发者应避免在析构函数中执行加锁或网络操作。 视频通过一个源自 Apache Spark 开源项目简化的最小复现案例,演示了仅因增大资源释放循环次数就导致程序死锁的诡异现象。高天深入剖析指出,当对象存在循环引用时,其销毁依赖于垃圾回收(GC),而 GC 会在不可控的时机被对象创建阈值触发;若在不可重入锁保护的临界区内突发 GC 并执行 `__del__`,且析构逻辑又去尝试获取同一把锁,就会酿成死锁。针对此问题,高天揭示了诸如 gRPC 内部持锁的工业界真实隐患,指出 `del` 关键字并不等于调用 `__del__` 的误区,并提出用上下文管理器临时禁用 GC 的妥协手段,以及彻底解耦资源与对象生命周期的架构规范。 在持有不可重入互斥锁的临界区内,大量创建对象可能越过 GC 阈值并触发循环引用对象的 `__del__`,若析构函数再次请求同一把锁,将引发自我死锁。 (1:14) 生产环境中第三方组件(如 gRPC)内部自带的线程锁同样容易受到 GC 析构时机的干扰,即便业务代码未显式加锁也可能导致死锁。 (3:22) 常见的技术误区是将 `del` 关键字等同于立即调用 `__del__`,实际上 `del` 仅减少引用计数,对象回收与析构时机完全由引用计数和分代垃圾回收器决定。 (3:22) 规避 `__del__` 锁竞争的临时方案是在临界区内使用上下文管理器包裹 `gc.disable()`,而根本解法是避免在析构函数中执行涉及加锁或 I/O 的操作,解耦资源生命周期与内存管理。 (3:22)

Reading summary

高天结合源自 Apache Spark 相关的工程案例指出,在含有锁操作的代码中滥用 Python 析构函数 `__del__`,容易在循环引用触发不可控的垃圾回收时引发同一线程内的不可重入死锁。 循环引用与 GC 不可控触发 (1:14) Python 绝大部分对象的销毁依赖引用计数即时完成,但一旦代码中构造了循环引用,对象即使脱离了外部作用域,引用计数也不会清零,只能等待分代垃圾回收器(GC)介入。垃圾回收的运行取决于新创建对象的分配计数阈值,开发者最安全的认知假设应当是“它可以在任何时刻被触发”。在模拟测试中,当释放流程仅生成 50 个新对象时程序完全正常,而一旦 workload 增加到 1000 个对象跨越阈值,或者显式调用 `gc.collect()` 时,垃圾回收便会被强制启动。 不可重入锁与析构函数的自死锁链条 (1:14) 死锁的本质在于临界区内突发调用了不可重入锁。外层释放流程在获取互斥锁(Lock)后执行释放操作,若在此期间因创建对象达到阈值触发了 GC,GC 会扫描并清理无用的循环引用对象,从而调用其 `__del__` 析构函数。由于 Python 默认的互斥锁不支持重入,当 `__del__` 内部再次调用释放函数并试图获取同一把锁时,已持有该锁的当前线程便会陷入永久等待自己的死锁状态。 工业级隐患与 del 关键字的认知误区 (3:22) 在生产环境中,这类问题往往更为隐蔽。例如在实际的 Spark 业务中,`__del__` 内部发起的 gRPC 远程调用本身就维护了内部锁机制,即使开发者未显式加锁,也会在 GC 介入时被其内部锁背刺。许多开发者常有误区,以为执行 `del` 关键字就能直接触发 `__del__` 析构函数,但 `del` 实际上仅是解除变量绑定并减少一次引用计数,绝非同步执行析构。 规避 GC 陷阱的妥协方案与治理原则 (3:22) 针对无法完全移除 `__del__` 的业务场景,一种妥协的临时规避手段是通过上下文管理器在进入加锁临界区前显式执行 `gc.disable()` 关闭垃圾回收,待操作完成后再通过 `gc.enable()` 恢复,防止临界区内发生突发性 GC。但长远的架构治理应当是解耦:遵循能不用 `__del__` 就不用、保持其绝对简单的原则,严禁在其中执行加锁、网络 I/O 等复杂操作,避免将外部关键资源的生命周期与 Python 内存对象的生命周期强行绑定。 高天提醒开发者在编写 `__del__` 时必须建立清醒预期:该函数可能在代码执行的任意一条指令处被调用。只有在架构上避免在析构阶段执行加锁逻辑并实现资源生命周期解耦,才能彻底杜绝隐蔽的死锁风险。

Original transcript

[0:00] 好久没有录技术视频了啊今天我们聊一个我在工作中遇到的有趣的问题那我们把遇到的那个实际的问题给抽象简化了一下啊变成这么一段代码大家如果对原始问题感兴趣可以去spark上面看啊都是开源的我们想象一段程序啊他用到了一些全局的资源然后到最后要release这个资源然后release有可能是多线程的我还特意给它设计了一个lock每一次release的时候我先拿到这个lock然后判断我们是不是已经进行完释放了如果已经进行完了就不干这事了那么我们判断和复制都是被lock保护的所以不存在这个竞争问题对吧好 如果是第一次释放的话 我就随便做一点workload 假装我再释放一些资源 我现在有一个class a 写的非常简单有一个看似有点玄机的一内坛数另外就是一个Dial函数也就是这个object被释放时候会运行的东西我们定义了一个函数F,F里面建了一个A,然后调用这个F在整个程序最后的时候我们release一下资源我们现在给大家5秒钟的时间读一下这段代码问问自己这段代码有问题吗好觉得时间不够的想挑战一下自己的可以暂停在这读啊我们运行下这边来玩诶没有问题妈我们把这个50改成1000增加一些release时候的workload再次运行你就会发现这个程序卡住了事实上我可以告诉大家这个程序在这死锁了但是你能想得明白为什么思索吗或者说你现在能反应过来为什么50变1000会让他思索吗我们Ctrl+C出来可以看到确实是卡死在这个锁这儿了事实上如果你把第18行这个看起来有点奇怪的代码给注释掉这段程序就可以正常的运行了如果讲到这里能大概猜到发生了什么你就可以给自己的python水平打60分了第18行有点像咒语的代码它起到了什么作用呢它的作用很简单 就是产生循环引用而在这个例子中引入循环引用的目的是让class A的object不要简单的被引用技术回收由于循环引用的存在在F这个函数运行之后虽然A括号这个对象已经没有外部引用了但是它还有内部引用所以在此刻它没有直接被垃圾回收他需要等到下一个垃圾回收触发的时候才会被回收而下一个垃圾回收什么时候被触发呢对于普通的拍档用户最安全的想象是它可以在任何时刻被触发那对于我们这个例子本身我再稍微多讲一点点垃圾回收的触发是跟我们产生的object的数量有关的所以在这里我其实是在偷偷产生新的object从而触发垃圾回收50个新的object并不足以触发这个垃圾回收但是1000个就出发了而触发之后会发生什么事情这个DEL函数会被调用而Dial函数会调用ReleaseResource我们这个时候再看lock就能想明白了吧我们相当于在ReleaseResource里面又调用了ReleaseResource而这个lock是不能reentry的我们在拿着这个锁的同时由于内部的调用再去拿这个锁就形成了思索事实上写成这样只是为了迷惑大家我们可以非常轻松的通过强制触发垃圾回收来复现这个问题大家可以看到只要运行一下gc.collect就卡在这了那这件事告诉我们什么我觉得最重要的是一定要小心这个DEL函数你在这里面写的任何东西你都要留意一下他有没有可能在某一个地方去拿锁只要他会去拿锁就很有可能deadlock在我们真实的案例里这里面做的其实是一个grpc调用而grpc调用的时候它有一个内部的锁这个锁甚至都不是我们自己写的但是因为你在调用grpc的时候拿了锁你就有可能会死锁在那所以大家在使用这个函数的时候一定要非常小心或者说能不用就不用因为你真的很难控制这个函数什么时候会被执行那很多人会有天真的幻想对吧通过Dell这个关键字来直接触发Dell这个函数有这个想法的同学们 多看看我的视频 你们还有很大的进步空间你说我确实避不开了我必须要这样我怎么办呢我能想到的相对来说还算可行的方案就是在你拿锁之前你把垃圾回收直接给disable掉当然我这里写的比较丑你把它写成一个context会好看很多包括context你也可以写在外面调用的时候大方向就是保证在拿锁的这段时间没有垃圾回收触发的奇奇怪怪的东西当然在设计上更好的方案应该是让这个DEL函数更简单一些不要去做需要锁的事就是资源的生命周期不要和对象的生命周期强绑定或者你压根就没有这个DEL函数对吧那就万事大吉那今天说这些主要就是希望 [5:00] 以后看到自己的程序里面有这个函数的时候一定要过一下脑子这个函数很危险当你在写这个函数的时候你就应该意识到这个函数是有可能在你代码的任何部分被调用的你要先接受这个前提再去写这个函数好那这期视频就得下喜欢的话接下来关注不要忘了一件三零哦优优独播剧场——YoYo Television Series Exclusive