ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python 内存管理机制与垃圾回收:从引用计数到循环引用调优实践

Python 内存管理机制与垃圾回收:从引用计数到循环引用调优实践 如果你写过长时间运行的服务或者做过数据采集、算法训练这类偏后台的 Python 开发大概率遇到过一种现象程序跑着跑着内存占用越来越高最后被系统杀掉或者 GC 卡顿明显CPU 无端飙高。很多人第一反应是“代码里有内存泄漏”但排查半天发现对象确实被释放了问题却出在 Python 自己的内存管理机制上。Python 通常被当成胶水语言用业务代码离内存分配很近但离内存管理又很远。绝大多数调用方只关心obj SomeClass()和del obj并不清楚这个对象的引用计数什么时候归零、什么时候进入垃圾回收器、什么时候真正把内存还给操作系统。标题里这几个名词——Python 内存管理机制、垃圾回收、引用计数——其实是一条完整链路分配对象、跟踪引用、探测垃圾、回收循环引用、缓存复用。只有把这条链路上每一环都看清楚你才能解释“内存为什么没降下去”“为什么循环引用测不出来”“为什么产生了大量gc.garbage却没人处理”。这篇文章我会从实际踩坑的角度拆这条链路。没有源码逐行分析但有核心机制、典型实验和你在日常项目中用得上的调优思路适合已经开始用 Python 写真实项目的开发者也适合准备系统学 Python 原理的人当索引。1. 为什么我们今天还需要把内存管理搞清楚1.1 从“程序为什么会卡”说起现代机器内存越来越大Python 本身又自带回收机制所以不少人觉得不需要管内存。但这个观点只对存活时间短、跑完就退出的脚本成立。一旦程序需要持续运行比如 Web 服务、消息队列消费者、定时任务进程内存增长就变成可用性问题。我见过一个典型的业务场景一个爬虫任务在循环里不断请求 API、解析 JSON、存放结果跑十几个小时后内存从最初的 200MB 涨到 3GB。用tracemalloc一查最耗内存的快照不是某个缓存字典而是由一长串异常对象组成的循环引用链条。异常被捕获并保存到列表里异常上下文又互相持有引用导致引用计数永远无法归零。最终回收它们的是分代垃圾回收而不是引用计数。如果没有把两层机制分开理解你根本定位不到真正的根因。还有一类问题是“卡”而不是“涨”。Python 的分代垃圾回收器一旦启动会遍历整个代里的对象图。当你的业务对象数量上了量级比如千万级的小对象哪怕没有循环引用gc触发的扫描也会带来明显的停顿。此时你会发现freeze、disable、手动collect这些方法就很有价值。换句话说内存管理不是“出了事才查”而应该是设计阶段就要做的课题。1.2 CPython 分配内存的宏观流程先建立一个全局图景。CPython我们讨论的就是默认的那个 Python 实现里的对象分为两个层面Python 对象层和底层内存分配层。你在 Python 里调用obj [1, 2, 3]解释器会做这几件事调用PyList_New系列函数创建列表对象包括头部结构体和元数据。从内存分配器申请一块内存。CPython 内部有专门用于小对象的 pymalloc 分配器按 8 字节对齐分块避免频繁调用 C 语言的malloc/free。对象头部记录类型指针、引用计数等信息然后引用计数初始化为 1。分配完成后这个对象由当前作用域变量指向后续赋值、传参、放进容器都会改变引用计数。关键点在于CPython 并不会在对象被删掉的瞬间就把内存还给操作系统。它会先把内存块归还给 pymalloc 的缓存池后续创建同类型同大小的对象时直接复用省掉一次系统调用。这也是“Python 内存不下降”的常见原因之一内存还给了 Python 分配器没有还给 Linux而你用top看的是常驻内存 RSS它当然不会降。要验证这一点最简单的实验是创建一个再删除几百万元素字典然后用resource.getrusage查看最大内存。你会发现峰值很高但程序后续内存并没有完全回退因为内存分配器留下了缓存。这不是泄漏而是内存复用策略。2. 引用计数Python 的基础策略也是最大隐患2.1 引用计数到底在数什么引用计数是 CPython 内存管理的第一道防线。每个 Python 对象的头部都有一个ob_refcnt字段它的含义是“当前有多少个地方指向我”。这个“地方”包括变量名、属性、容器元素、传入函数的局部变量、全局字典、模块对象、栈帧里的临时引用、异常上下文等等。每次你执行a object() b a只要多一个引用ob_refcnt就加 1del b或者b被重新赋值引用计数就减 1。当计数变成 0对象立刻被销毁也就是tp_dealloc被调用然后该释放的内存释放。这一机制是确定性的不存在“过一段时间才回收”的说法所以 Python 没有 C 那种悬垂指针的问题只要引用计数正确对象生命周期就是明确的。理解引用计数最简单的类比是图书馆的借阅记录。一本书有多个读者在借每借一次记录加一个名字每还一次划掉一个名字所有名字都没了书才能放回仓库。Python 的引用计数就是这个借阅记录而且是全自动的只不过登记员偶尔会漏掉“自己借给自己”这种特殊情况。看个小例子import sys a [] print(sys.getrefcount(a)) # 2getrefcount 自己也会引用一次 b a print(sys.getrefcount(a)) # 3 del b print(sys.getrefcount(a)) # 2sys.getrefcount不是“真实的”引用计数因为你调用它的时候这个临时参数本身也会导致计数加 1。所以一般用它调试时要把结果减 1。2.2 引用计数的代价与优化引用计数有两个明显的代价。第一是每次执行赋值、传参、容器操作都要修改引用计数这是一个原子操作存在 CPU 开销。第二是它要求开发者或解释器确保每个引用都被正确处理一旦漏掉一个应该减引用的情况对象就会无限期存活即使它已经不再被业务逻辑使用。CPython 为此做了一层优化针对局部变量这种生命周期非常明确的场景解释器在编译函数时会生成专用的字节码STORE_FAST和LOAD_FAST这些指令知道变量的基本块范围会在函数帧结束时批量调整引用计数而不是在每次变量重新赋值时都做完整的增减操作。这是 CPython 性能优化的一个典型手段——减少引用计数操作次数而不是修改引用计数的基本语义。另一个常见优化是“内部临时引用”。解释器在执行某些表达式时会在栈上创建临时引用但这些临时引用对用户不可见且生命周期极短。例如len([1,2,3])里的列表对象会有一个临时引用指向它表达式结束后计数就恢复。如果你在__del__里做反射式检查有时会看到诡异的引用计数数字这是临时引用的干扰。2.3 为什么循环引用会漏掉引用计数最大的盲区是循环引用。假设class Node: def __init__(self): self.next None a Node() b Node() a.next b b.next a del a del b这个“环”不再被任何外部变量引用但内部两个对象之间互相引用各自引用计数都无法变成 0。a被b.next引用b被a.next引用。引用计数不知道“外部已经没人引用这一团了”于是它放弃了这堆对象。更隐蔽的是自引用链比如异常对象try: raise ValueError(bad) except ValueError as exc: exc.__traceback__ exc.__traceback__.tb_next # 可能形成环__context__、__cause__、__traceback__都可能形成链式引用如果异常对象又被保存在全局列表里垃圾回收器要费很大力气才能判死这些对象。要检测一个对象是否形成了循环引用单靠sys.getrefcount是不够的。更直接的方法是用gc.get_referrers回查哪些对象引用了当前对象或者用第三方库objgraph画出对象图。我在定位循环引用时最常用的模式是import gc import objgraph gc.collect() objgraph.show_refs([some_object], filenamerefs.png)gc.collect()先把可回收的垃圾清掉再看当前对象还有谁在引用这样能过滤掉已经进入垃圾链路的对象。3. 分代垃圾回收循环引用的“收尾者”3.1 gc 模块的分代结构引用计数负责快速回收无环对象分代垃圾回收负责扫掉循环引用。CPython 的 GC不是 Java 的 GC是gc模块背后的机制把对象分成三代第 0 代是新生代第 1 代是次新生代第 2 代是老年代。新创建且支持垃圾回收追踪的对象默认进入第 0 代。这里有个不少新手容易混淆的点不是所有对象都进 GC。int、str、float这些基础不可变对象本身不会形成循环引用所以它们不会被gc跟踪。列表、字典、自定义类的实例会被追踪因为它们可以持有任意引用包括互相引用。每代维护一个链表当某代的对象数量和“被分配次数 - 被释放次数”的累计值达到阈值就会触发一次该代的回收。gc默认阈值是(700, 10, 10)这个三元组的含义并不是“第 0 代 700 个对象就回收”而是“第 0 代累计分配 700 次就回收回收后幸存对象升到第 1 代当第 1 代累计升代 10 次后回收第 1 代当第 2 代累计升代 10 次后回收第 2 代”。阈值机制听起来很绕你可以理解成一种“分代晋升”对象活过第一次回收就搬到更老的代里老代回收频率低因为通常老对象生命周期长没有必要频繁遍历。3.2 阈值是如何工作的gc的源码里有三个计数器。比如程序创建了一个列表参与计数的allocations加 1释放一个容器对象deallocations加 1。gc判断的不是当前存活对象数而是从上次回收至今的净分配量。当allocations - deallocations threshold_0就会触发第 0 代回收。我写了个简单实验来理解这个机制import gc print(gc.get_threshold()) # 常见输出 (700, 10, 10) for i in range(10000): a [] b [] a.append(b) b.append(a) del a, b print(gc.get_count())gc.get_count()返回一个三元组(count0, count1, count2)分别表示当前三代中各自累计的未收次数。跑完循环再调用它你会发现第 0 代计数不会无限增长因为已经触发过多次回收并重置了。手动触发整体回收用gc.collect()它返回“被回收的对象数量”这个数值还挺有用可以用来判断循环引用是否真的存在。如果一段代码删除了所有引用后gc.collect()返回大于 0说明有循环引用。3.3 分代回收为什么不处理临时对象分代回收能找出循环引用但代价是完整的图遍历。它需要从根对象出发沿着引用链逐步标记所有可达对象然后清除不可达对象。对于大量短命对象比如函数内创建的临时列表和字典如果它们不能被引用计数快速回收GC 扫描会变成性能瓶颈。所以 CPython 的设计哲学是能用引用计数解决的不交给 GC。你看一个纯粹不包含循环引用的临时对象比如def f(): a [1, 2, 3] return sum(a)这个列表在函数返回后立即被引用计数清零并销毁从头到尾不参与 GC 扫描。只有当一个对象“活了足够久”触发某个代的回收时才会被遍历一次。这种分层设计让 Python 在学习成本不高的情况下达到可接受的内存管理效率。但因为 GC 是“懒惰的”它不会立刻发现循环引用。极端情况下程序会在某次gc.collect()或阈值触发时一次性回收大量对象造成“间歇性卡顿”。如果你做的是游戏服务器、实时数据流这类延迟敏感的任务就必须主动介入 GC 的触发时机而不是任由它凑到阈值再清理。4. 用真实案例调优和诊断内存问题4.1 我遇到的循环引用和del问题循环引用本身不是大问题但如果你给类写了__del__方法问题就来了。CPython 的 GC 在发现不可达的循环引用对象时如果对象定义了__del__它无法安全销毁因为__del__可能在对象被部分清理时仍有外部引用。此时对象会被放进gc.garbage列表由程序员自己处理。我遇到过一个很典型的案例一个缓存模块里的对象有__del__方法用于关闭数据库连接对象之间存在双向引用。程序每天凌晨做一次大清理调用del后连接数并没有减少。一查gc.garbage发现里面堆了几百个缓存对象。原因就是这个循环引用和__del__的组合。解决方案不是去手动清空gc.garbage而是从设计上消除__del__。Python 3.4 之后引入了__del__过程中的PEP 442让大多数带__del__的循环引用对象可以被安全回收但代价是这个对象的__del__是否真的执行到位不确定。更可靠的做法是用资源管理器接口替代析构函数class Connection: def close(self): self._conn.close() class Cache: def __init__(self): self._conn Connection() with Cache() as cache: pass把资源释放从__del__移到上下文管理器里循环引用销毁时就不用依赖__del__的时机。4.2 弱引用是怎么回事弱引用weakref.ref是解决循环引用和长生命周期缓存问题的优雅手段。它指向对象但不增加引用计数因此不影响对象是否被回收。当对象被销毁弱引用会自动失效。举个例子一个全局对象缓存import weakref class HeavyObject: pass cache weakref.WeakValueDictionary() def get_obj(key): if key not in cache: cache[key] HeavyObject() return cache[key]WeakValueDictionary的 value 是弱引用。如果不小心把 key 写成强引用或者 value 被强引用变量持有WeakValueDictionary 就会退化成普通字典。这个“退化”特别容易被忽略比如你在缓存函数里写了def get_obj(key): if key not in cache: cache[key] (HeavyObject(), time.time()) # value 是元组不会被弱引用元组对象本身不是被弱引用的它又强引用着 HeavyObject所以缓存永远不会被回收。想要弱引用缓存value 就必须是weakref.ref本身或者使用支持弱引用的weakref.WeakValueDictionary并且不能把 value 换成包装结构。我自己的经验是缓存“不可变且有价值”的对象用弱引用很合适缓存“频繁创建但处处都在用”的临时对象则不应该用弱引用因为一旦缓存频繁失效你反而会频繁重建对象导致效率下降。4.3 tracemalloc 和 gc 的调试输出大部分内存问题不是靠肉眼猜出来的而是靠状态快照对比。Python 自带的tracemalloc模块可以记录分配内存的栈信息特别适合定位“哪一行代码创建了最多对象”。在程序开头启动import tracemalloc tracemalloc.start(10) # 运行一段时间后 snapshot tracemalloc.take_snapshot() top_stats snapshot.statistics(lineno) for stat in top_stats[:20]: print(stat)start(10)表示最多保留 10 层栈帧栈深够用就行太深会显著拖慢程序。statistics(lineno)按代码行聚合返回每一行的总分配量。输出里能看到类似test.py:5: size100 KiB, count10, average10 KiB的信息这样能立刻定位到分配热点。gc模块也可以开启调试输出import gc gc.set_debug(gc.DEBUG_LEAK | gc.DEBUG_STATS | gc.DEBUG_OBJECTS) gc.collect()开启之后在下次回收时所有被回收和无法回收的对象信息都会打印到标准错误里。如果你看日志非常杂乱可以精确到某个操作gc.collect() before gc.garbage # 业务代码 gc.collect() after gc.garbage print([obj for obj in after if obj not in before])DEBUG_LEAK会输出未被清楚释放的容器对象这些对象不是一定泄漏了而是没有被 GC 清掉或者仍被引用。配合objgraph按类型统计对象数量能很快看出哪种类型的对象在堆积。5. 在日常开发中应该养成的内存习惯5.1 区分“内存泄漏”和“内存持续膨胀”排查内存问题时我先会做一个判断到底是真正的内存泄漏还是内存被分配后没有归还操作系统。真正的内存泄漏指对象永远存在且不可达内存持续膨胀往往是代码持有不必要引用、缓存无限增长、循环引用未处理或分配器缓存复用不足。验证方法很简单常驻内存 RSS 一直涨但gc.get_object_count()或len(gc.get_objects())稳定说明没有对象泄漏问题可能出在 C 层面的缓冲区或者分配器缓存上。如果len(gc.get_objects())持续上涨则是 Python 对象层面的问题。一个常见误区是频繁创建大列表再丢弃。列表虽然被引用计数清掉了但 pymalloc 可能保留了很大一块 arena。如果业务是一次性处理海量数据完成后内存不回落是正常的系统会保留空闲内存以便后续复用。这个场景里硬调gc.collect()也没用因为gc.collect()只管循环引用和部分对象释放不管 C 分配器的缓存。5.2 什么时候考虑禁用 GC / 调整阈值gc.disable()可以完全关闭自动垃圾回收。建议不要全局禁用除非你能确定程序生命周期内不会产生循环引用且有其他兜底策略。对某些性能敏感的应用合理做法是调大阈值降低扫描频率然后在业务低峰期手动gc.collect()。调阈值的方式import gc gc.set_threshold(1500, 15, 10)把第 0 代阈值从 700 提高到 1500 之后短生命周期对象触发 GC 的频次降低但代价是第 0 代对象可能堆积更多单次扫描时间变长。如果你不想 GC 扫描影响核心路径可以在初始化时# 只在必要时运行 gc.freeze()freeze()会把当前存活对象移到最老一代并永久标记为不可回收用于冻结启动阶段创建的大对象避免后续每次 GC 都扫描它们。这个操作对 Web 应用启动过程很友好常在 fork 之后调用集中处理预分配对象。如果选择手动管理 GC我建议在关键路径上做分类核心业务循环里避免创建不必要的容器对象对外批量任务入口处调用一次gc.collect()并记录返回的回收数量作为监控指标。这个指标如果长期偏高就说明代码里存在大量循环引用垃圾应该去查业务逻辑而不是调 GC。5.3 一些个人经验最后分享几条操作层面的经验都是踩过坑之后总结的。第一尽量不要在容器对象里保存自己的引用或不规范的嵌套引用。虽然 GC 能处理循环引用但处理需要时间。如果你控制不了对象图至少用弱引用来表示“知道对方但不影响对方生死”的关系。第二__del__是最后手段不是第一手段。在 Python 里如果想在对象销毁时释放外部资源优先用contextlib的上下文管理器或者在业务类里提供显式的close()方法。不要指望__slots__解决了内存问题就不看引用关系__slots__只是减少每个实例的内存占用不影响 GC。第三监控比事后优化更重要。在长期运行的服务中定时记录gc.get_count()、len(gc.get_objects())、gc.garbage的长度。三个指标构建一个简单的内存健康面板。突然大量上涨时结合快照对比就能迅速定位问题不必等到崩溃。第四调 GC 阈值要配合压测。大部分人只是把阈值调大调小缺乏量化依据。正确做法是先保持默认阈值跑一遍基准业务记录 GC 触发频率和单次耗时再尝试不同阈值组合观察响应时间和内存峰值的变化。这里没有万能参数不同业务对象生命周期分布决定了不同的最优配置。Python 的内存管理机制看似只有“垃圾回收”四个字实际上是一套由引用计数、分代回收、内存池缓存共同构成的复杂系统。理解它的价值不在于能背出原理而在于当你看到内存上涨、GC 停顿、弱引用失效时能快速判断出该把眼光放在哪一层并拿出可行的实验去验证。
返回列表