
你有没有遇过这种场景一个Python服务跑上一整天内存像温水煮青蛙一样一点点往上爬重启之后立刻恢复正常可业务代码翻来覆去也找不到哪里“泄漏”了我第一次认真研究这个问题是在一个常驻调度服务里——每3秒跑一轮任务内存稳步上涨查了半天IO句柄、日志堆积、外部连接最后发现真正的元凶根本不是业务代码而是我对Python内存管理机制的几个基础认知出现了偏差。这篇想聊聊CPython里的三件事引用计数、垃圾回收GC、内存池。它们共同决定了“对象什么时候死”“死了以后内存去哪了”“为什么内存看着高却未必是泄漏”。也会带上我用gc模块、objgraph、tracemalloc排查内存增长的实际套路。适合写过较长驻服务、被内存问题折磨过或者单纯想理解Python内存原理的开发者。1. 引用计数Python默认的“自动释放”引擎先强调一个大前提引用计数不是Python语言规范强制要求的是CPython解释器的核心实现。PyPy、Jython等实现用的是另一种垃圾回收策略。所以下面所有内容都限定在“CPython 3.x”这个语境下讨论这也是绝大多数人实际使用的环境。CPython里每个对象都是PyObject结构体开头两个核心字段引用计数ob_refcnt和类型指针ob_type。引用计数就是对象的“存活名片”它记录着当前有多少个变量、容器、临时位置指向这个对象。计数一旦归零对象立刻走析构流程并释放内存。1.1 从对象创建看引用计数的增减规则引用计数听着简单但实际增减点很多最容易漏的是“放入容器”和“函数传参”这两类。我整理了一张表覆盖了常见场景场景对引用计数的影响新对象创建变量指向它计数从0变1另一个变量引用同一对象1对象作为参数传入函数1函数返回局部变量生命周期结束对应局部变量的引用-1对象放入列表/字典/集合1对象从容器中移除-1变量被重新赋值成其他内容旧对象计数-1del某个变量对应对象计数-1代码验证最直接import sys obj [1, 2, 3] print(sys.getrefcount(obj)) # 2变量obj的引用 传给getrefcount时的临时引用 ref obj print(sys.getrefcount(obj)) # 3 del ref print(sys.getrefcount(obj)) # 2注意sys.getrefcount本身就有一个临时参数引用所以结果会比“真实引用数”多1。另外如果你在交互式环境或调试器里跑这段代码输出往往会更大因为工具的变量查看、历史记录也会持有对象。这时候别慌看增减趋势比看绝对值有意义。1.2 引用计数归零立即销毁与__del__触发和其他语言的垃圾回收相比CPython的引用计数有一个非常鲜明的特点确定性释放。意思是只要引用计数降到0对象马上被回收不需要等某个GC周期。class Demo: def __del__(self): print(Demo 被回收) d Demo() del d # 立刻打印 “Demo 被回收”这里有个经验陷阱在IPython或Jupyter里跑del d可能不会立刻打印因为交互环境会把变量存进历史记录相当于多了一个看不见的引用。真实业务代码里如果确认没有其他引用释放时机是可以预期的。1.3 引用计数的代价GIL下的线程安全与性能损耗引用计数不是免费的。每次赋值、函数调用、容器操作都要做计数加减还要检查是否归零这本身就是一笔持续的开销。更麻烦的是多线程不同线程可能同时操作同一个对象的引用计数加减操作必须原子化。CPython选择用GIL统一保护引用计数这也是GIL很难被彻底移除的原因之一——一旦去掉引用计数的线程安全就是个巨大问题。所以你会发现纯Python代码在频繁构造小对象时会很吃力。优化思路通常也在这里减少临时对象创建、复用结构、尽量用内置的高效容器。2. 循环引用引用计数解决不了的那道坎引用计数很直接但有一个结构性问题它只能管“引用链没有闭环”的情况。一旦对象之间互相引用计数就永远减不到零。2.1 环是怎么形成的写一个最经典的例子class Node: def __init__(self): self.parent None self.children [] a Node() b Node() a.children.append(b) # b 的引用计数 1 b.parent a # a 的引用计数 1此时a和b互为引用即使把外界对a、b的引用都删掉两者的计数仍然是1。程序退出前这组对象既无法被引用计数回收也确实不再是垃圾不是它们已经不可达了但它们互相把对方“撑住”了。并不是所有对象都会进入GC的视野。像整数、字符串这种不能引用其他对象的类型不需要被追踪只有可能形成引用网的容器、自定义实例才需要。可以用gc.is_tracked验证import gc class Node: def __init__(self): self.children [] n Node() print(gc.is_tracked(n)) # True实例的__dict__可能引用其他对象 print(gc.is_tracked(42)) # False整数无法引用他人GC的追踪对象是垃圾回收的工作范围引用计数是“即时释放”这两个机制是互补的。2.2 标记-清除从根开始找仍然活跃的对象GC面对循环引用的思路不是“数引用”而是“画地图”。它从根集合出发——模块全局变量、当前运行栈上的局部变量、调用帧等——沿着每个对象对外的引用一路遍历把所有仍可达的对象标记为“活着”。堆上没有被标记的对象全都视为垃圾统一回收。这个区别很关键垃圾回收里的“垃圾”定义是“从根不可达”而不是“引用数为0”。引用计数是从个体视角数“谁引用我”标记-清除是从整体视角判断“我还能不能从根被发现”。我常用的类比是引用计数像挨家挨户问“你们家有没有人记着隔壁老王”标记清除则是从居委会的名单出发把所有还能联系上的人画在一张通讯录里名单之外的人直接销户。两种思路各有代价维度引用计数标记-清除GC触发时机引用变化时即时处理分代达到阈值后扫描能否回收循环引用不能能释放确定性即时、可预测有延迟可能停顿主要开销每次赋值、传参都在做计数全堆扫描与标记2.3 PEP 442Python 3.4之后循环引用回收更可靠了这里有个老版本的大坑Python 3.4之前如果循环引用里的对象定义了__del__GC会因为无法确定终结器的调用顺序而拒绝处理它们把对象一股脑丢进gc.garbage列表需要开发者手动清理。很多老教程至今还在教“用gc.garbage判断泄漏”那其实是旧时代的产物。Python 3.4引入PEP 442实现了更安全的终结机制收集器会对需要执行__del__的对象做排序再逐个安全调用带终结器的循环对象也可以被回收了。所以现在排查内存问题基本不需要再过度担心“__del__导致循环引用无法回收”这件事。但__del__本身仍有其他坑这个放到第六章细说。既然GC能处理循环引用那是不是可以抛弃引用计数、全面改用标记清除不行。标记清除需要扫描整个堆频繁全量扫描会卡顿。于是CPython又引入了分代机制。3. 分代垃圾回收为什么年轻对象要先被照顾分代回收的核心来自一条实践统计绝大多数对象创建之后很快就死了。程序里大量临时对象存活时间极短只有少数对象能活过几次回收。既然如此就没必要每次都扫描全部对象把精力集中在“最可能产生垃圾”的年轻对象上最划算。3.1 分代假设与默认阈值新对象为什么频繁被扫描CPython把内存中的GC追踪对象分成三代0代、1代、2代。默认阈值是(700, 10, 10)触发逻辑是每一代记录“分配对象数减去释放对象数”当0代的这个差值超过700触发一次0代收集。0代收集后仍然存活的对象晋升到1代。0代收集每完成10次触发一次1代收集。1代收集每完成10次触发一次2代收集。所以2代收集的间隔大致是“新增对象数约70000个”的水平比0代宽松得多。年限越大的对象被扫描的频率越低。可以用gc.get_count()看当前各代的状态import gc print(gc.get_threshold()) # (700, 10, 10) print(gc.get_count()) gc.collect() print(gc.get_count())get_count()返回的元组含义是当前0代累计变更数、距离上次0代收集的0代收集次数、距离上次1代收集的1代收集次数。看着有点绕但配合阈值就能理解“下一代收集还要多久触发”。3.2 手动回收与阈值调参什么情况下需要动手大部分脚本用默认阈值就够但有些场景值得手动干预。场景一程序刚启动时一次性加载了海量数据、创建了大量临时对象之后进入稳定期。这时0代收集频繁触发过去的临时对象还会被晋升到1代甚至2代造成无谓扫描。可以考虑在启动加载完成后主动调用gc.collect()把“不再会变化”的一次性数据清理掉。场景二某个批量处理窗口内短期创建了几百万个小对象GC频繁触发导致卡顿。可以临时调高0代阈值甚至在这个窗口内gc.disable()结束后再gc.collect()。但长驻服务里绝对不要长期禁用GC否则循环引用会越积越多。阈值调多少算合理我的建议是先开gc.set_debug(gc.DEBUG_STATS)观察一段时间看每次收集的对象数量与耗时分布再基于数据调整。盲目把0代阈值调到几万很可能换来“每次收集卡顿几百毫秒”的代价。4. 内存池与空闲缓存区回收之后内存去哪了引用计数把对象“释放”了GC把循环垃圾“回收”了然后呢内存真的返回操作系统了吗答案往往是否定的。4.1 pymalloc小对象为什么不要走系统mallocCPython在系统malloc之上又实现了一层内存池专门处理小对象分配。以512字节为分界线小于等于512字节的请求由pymalloc管理超过512字节的大对象才交给系统malloc直接分配。pymalloc的逻辑很像批发市场。如果每个小对象都直接找系统要内存每次都要发起malloc系统调用频繁申请小块还很容易产生碎片。pymalloc会一次性从系统申请较大块区域再切分成固定大小的“池子”pool池子内部再按统一规格切成小块。对象释放后内存块留在池中复用下次分配同类大小对象时直接取走不再打扰操作系统。这种设计让大量小对象的分配和释放速度明显提升代价是这些内存不会立刻还给OS。从进程外部看RSS会维持在一个偏高水平。4.2 free list缓存为什么内存占用常常“降不下来”pymalloc只管内存块的复用而一些内建类型还在自己的类型结构里维护了“空闲对象链表”。比如float、tuple、list、dict在释放对象时会把有限数量的对象放在内部缓存里下次创建同类型对象时直接复用。这样做是为了加速同类高频操作同时减少了重复初始化的开销。带来的直接现象是Python进程在某些操作后内存上涨之后即便手动gc.collect()RSS也可能一点不降。因为那些内存都被缓存留着复用了不是泄漏只是“舍不得还”。判断是缓存还是泄漏最快速的办法是脚本里跑一次gc.collect()对比前后RSS再继续观察一段时间看内存会不会持续爬升到新高点。如果每次collect后都能回到差不多的基线基本是缓存回收滞后如果基线本身一路抬高才值得认真追查。4.3 快速判断实验RSS与gc.collectimport gc import resource import sys def rss_value(): # Linux 的 ru_maxrss 单位是 KB部分平台是字节跨平台比较先换算成字节 val resource.getrusage(resource.RUSAGE_SELF).ru_maxrss if sys.platform darwin: return val # macOS 下是字节 return val * 1024 # Linux 下是 KB print(collect 前 RSS:, rss_value()) n gc.collect() print(本次收集对象数:, n) print(collect 后 RSS:, rss_value())这个实验我建议在业务平稳期做不要在业务高峰做否则污染数据。连续采样几次配合时间线看趋势才有参考价值。5. 排障实操用gc、objgraph、tracemalloc定位内存增长理论部分讲完进入实战。我排查内存问题的套路基本固定先确认是不是GC能回收的垃圾再统计对象增长最后追分配源头和引用链。下面按顺序铺开。5.1 第一步排除Python缓存建立基线不要一上来就怀疑泄漏。先完成三个动作记录基线RSS、手动gc.collect()、再次记录RSS。如果collect后RSS回落明显说明一部分对象确实已经沦为垃圾只是回收滞后如果collect后RSS纹丝不动说明内存被某些“活的引用”占着。在长驻服务里我会在代码里临时加一个监控函数每小时记录RSS和gc.get_count()跑一整天再画出来看趋势。曲线能告诉你的信息比任何工具都多是持续线性上涨疑似引用链漏清理还是锯齿状上涨疑似GC滞后还是阶梯式上涨疑似缓存被批量填充。5.2 gc三板斧get_objects、get_count、set_debuggc.get_objects()返回当前GC追踪的所有对象。注意它不包含整数、短字符串这类不需要追踪的对象所以别拿它当“全量对象清单”。统计对象类型分布是常规操作from collections import Counter import gc gc.collect() counter Counter(type(obj).__name__ for obj in gc.get_objects()) for name, count in counter.most_common(15): print(name, count)如果对比两个时间点的快照某个类型数量疯涨线索就出现了。比如Node从1000涨到100万方向基本锁定。gc.set_debug(gc.DEBUG_LEAK)可以开启详细的回收日志输出哪些对象被回收、哪些无法回收。这是排查“为什么某个对象死不掉”的好帮手import gc gc.set_debug(gc.DEBUG_LEAK) # 跑一段触发回收的业务逻辑 gc.collect()日志里会打印gc: collectable Node object at 0x...之类信息。一旦看到大量疑似不该存在的对象出现在输出里就去看看谁一直在引用它。还有一个小众但好用的APIgc.freeze()。在multiprocessing用fork创建子进程后子进程会继承父进程全部对象引用如果服务跑了一段时间才fork子进程的GC扫描负担会很重。在fork之后调用gc.freeze()可以冻结当前对象避免它们进入后续代的追踪范围能显著加快子进程的GC过程。5.3 objgraph把对象引用链画出来gc能告诉你“有哪些对象”但回答不了“谁在引用它”。objgraph正好补这块短板它可以用Graphviz生成引用关系图。pip install objgraph在代码里简单使用import objgraph # 查看最近内存里增长最快的前10类对象 objgraph.show_growth(limit10) # 对某个可疑对象画引用链 objgraph.show_refs([suspicious_obj], max_depth5, filenamerefs.png)show_refs生成的图里你能直观看到目标对象被哪个根对象或中间对象“拿住”了。我通常这么用先用show_growth找增长最异常的类型再在业务代码的稳定期拿到一个该类型实例最后show_refs定位引用路径。视觉化面对复杂引用网时效率远高于人肉翻代码。5.4 tracemalloc定位对象分配的真实源头objgraph解决“谁持有它”tracemalloc解决“谁创建了它”。这是Python 3.4内置的模块能精确到“哪个文件哪一行代码分配了这个对象”。import tracemalloc tracemalloc.start(25) # 保留更深调用栈 # 运行需要观察的业务逻辑或者部署后运行一段时间 snapshot1 tracemalloc.take_snapshot() # 继续运行 snapshot2 tracemalloc.take_snapshot() for stat in snapshot2.compare_to(snapshot1, lineno)[:15]: print(stat)输出的stat会显示文件路径、行号、内存增量和分配次数例如/usr/local/app/service.py:128: size18.2 MiB, count34000。看到这个就很容易定位到具体模块。注意tracemalloc有一定性能开销生产环境不建议全景长期开启。我通常只开一个短时间窗口比如在服务刚启动未接入流量时tracemalloc.start()运行半小时后做一次snapshot对比足够抓到大多数热点。6. 规避内存泄漏三个值得记住的坑很多“内存泄漏”其实不是Python的GC不工作而是业务设计上让对象一直从根可达。下面这几个坑我都在真实项目里踩过。6.1 坑一全局缓存持有“该淘汰却永不淘汰”的对象假设有一个全局字典缓存键是数据版本值是加载后的大数据对象。数据对象内部又把自己放进了某个容器形成循环引用。循环引用本身不是问题问题是全局字典作为根让这些对象永远可达GC根本不认为它们是垃圾。这种问题的根不在循环引用而在缓存没有淘汰策略。改用弱引用缓存或者给缓存加条数上限、时间过期才是正向解法。否则无论怎么调GC阈值都无济于事。6.2 坑二闭包与回调隐式持有大对象闭包会捕获外部变量捕获的变量又可能牵住一整条引用链。比如某个定时任务模块注册了一个回调函数回调内部引用了业务对象业务对象又引用了超大历史数据。定时器一直注册着回调就等于每条引用链都被根对象长期持有。排查思路event系统的回调列表里找“看起来没清理”的注册项异步任务队列里检查是否有任务长期排队持有数据。对需要长期存在的订阅关系尽量让回调只持有必要的小对象大对象通过weakref.ref或ID在真正使用时再去取。6.3 坑三__del__里的意外与弱引用的正确姿势__del__有两个麻烦一是执行时机不可预测尤其对象在循环引用中时回收可能延后到GC周期二是在__del__里抛异常解释器会打印到stderr并吞掉异常很容易掩盖资源未释放的问题。终结逻辑的正确姿势是尽量try/finally包起来把真正的资源清理放到上下文管理器而不是__del__。如果只是想“缓存一份对象但不想阻止它被回收”弱引用是更安全的方案import weakref class HeavyData: pass cache weakref.WeakValueDictionary() def get_data(key): data cache.get(key) if data is None: data HeavyData() cache[key] data return data弱引用不增加对象的引用计数所以对象不会再因为缓存而“想死都死不掉”。对象真的被回收后对应的弱引用条目也会自动失效下次访问时返回None并重新创建。这是大对象缓存最推荐的实现方式之一。讲完这些我自己的实际感受是排查内存问题最忌讳“怀疑GC不干活”。多数时候GC都没问题是从根可达的一条引用链把一个本该销毁的对象死死拽住。按“先跑gc.collect()看回落再看对象类型增长最后trace分配源头”的顺序走一遍基本都能收敛到具体模块。把这些机制记清楚内存问题就谈不上玄学了。