ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Python GC深度解析:引用计数、分代回收与实战调优

Python GC深度解析:引用计数、分代回收与实战调优 先说我自己的经历。去年排查一个常驻数据服务的RSS内存持续上升问题时按Java项目的习惯第一件事就是找GC日志、看GC频率。但Python环境里翻了半天也没找到什么“GC日志”——Python的GC默认不打印除非你主动开debug开关。后来我把gc模块的各个参数摸了一遍才发现Python里的“垃圾回收”和我原本理解的Java式GC根本不是一回事。标题里这句话“在Python中GC是自动管理内存的机制用于回收不再被引用的对象所占用的内存”听起来很简单但落到实际排查时你会撞上“引用计数”“分代回收”“循环引用”“标记清除”“终结器”这一堆概念它们各有各的职责也各有各的坑。这篇文章就把这些层面拆开讲透同时聊聊我实际踩过的问题和调优经验适合对Python内存管理想建立完整认知的人也适合正在为“内存只增不减”发愁的开发者。1. Python的回收主力和辅助清理是两个完全不同的东西很多人把“Python的GC”当成一个整体机制但实际上CPython的自动内存管理至少由两层组成而且这两层的设计逻辑截然不同。1.1 引用计数真正干脏活累活的第一道防线CPython每个对象头上都维护着一个字段叫引用计数对应C API里的PyObject.ob_refcnt。当你把一个对象赋值给新变量、塞进列表、作为参数传进函数时这个字段就会加一当变量被重新赋值、函数弹栈、对象从容器移除时这个字段就会减一。一旦计数归零对象立刻走tp_dealloc逻辑把内存释放掉。这套机制是嵌入在所有操作里的不需要“GC线程”去感知也不需要“暂停程序再扫描”。你可以随时在自己的代码里验证这种实时性import sys class Demo: pass obj Demo() # sys.getrefcount(obj) 的结果总是比真实计数大1 # 因为把 obj 作为参数传进去时这个函数本身也让引用临时1 print(sys.getrefcount(obj)) # 通常输出2把对象放进容器计数会继续涨lst [obj, obj] print(sys.getrefcount(obj)) # 输出41个变量引用 2个列表元素 1个函数参数引用计数在后台左右着几乎所有对象的生死。del obj其实只是删掉了“名字到对象”的绑定并不直接销毁对象只有计数归零后销毁动作才真正发生。1.2 分代回收专门处理引用计数解决不了的循环引用既然引用计数实时又高效为什么还需要一个“GC”因为引用计数有一个天然盲区循环引用。拿最简单的两个对象说class Node: pass a Node() b Node() a.next b b.next a del a del b这时a被b.next引用着b被a.next引用着两个对象的引用计数都还是1但它们对外已经没有任何路径能被代码拿到了。它们互相抱着永远不死。这就是“泄漏”——不是内存泄漏定义里的那种失控增长而是垃圾回收器看不见的垃圾。分代回收garbage collector也就是gc模块管理的部分就是为这个场景设计的它周期性从根对象出发沿着引用关系遍历所有对象能到达的就是存活对象到不了但形成环的就是可回收对象。这个“找环并回收”的过程就是我后面要展开的gc.collect()背后的逻辑。1.3 Python的GC和Java/Go的GC有什么本质不同很多刚从Java转到Python的人会把两者画等号这是排查问题时的第一道门槛。JVM的GC是“集中式垃圾回收”阶段性地停止应用线程标记、清理、压缩工作量大但算法成熟。CPython的GC则是“引用计数 定时清扫”的组合拳引用计数处理绝大多数对象的即时回收分代回收只处理偶尔出现的循环引用。这意味着Python里对象的销毁时点非常确定——引用归零的那一刻就回收了不归零就一直不回收。而Java的回收时点基本不可预测。反过来Python每个赋值、每个传参都在做引用计数的加减操作存在CPU消耗JVM则把这类操作的时间省下来集中在GC阶段花。所以“Python的GC慢”往往不是分代GC慢而是引用计数这种零碎操作贯穿在每一行代码里的累积成本。2. 引用计数如何决定一个对象的生死这一节重点讲引用计数在日常代码里的真实表现以及几个特别容易让人误判的细节。2.1 引用计数增减的全过程一个对象从生到死引用计数会经历无数次的加减。看一个带函数的例子def process(items): # items入参时调用方的引用计数1 total 0 for item in items: total item # 函数返回后items局部引用销毁调用方的引用计数-1 return total numbers [1, 2, 3] # numbers引用计数1 result process(numbers) # 传参时计数2函数返回后再回落到1 del numbers # 计数归零列表对象被释放真实运行时到底加了多少次、减了多少次比这个例子复杂得多但逻辑主线就是这样每个引用关系的建立和消失都反映在计数上。有一类高频场景需要特别注意把同一个对象放进多个列表或把对象作为多个字典的值。很多人在写ORM、写缓存结构时同一个模型实例被塞进多个索引容器这时你以为删掉原始变量就释放了实际上引用计数还高得很。对象只有在所有容器都移除之后才会释放。2.2 小整数、驻留字符串引用计数归零不了的“永生对象”CPython里有一个特殊机制-5到256之间的小整数是全局缓存的。无论你在代码里写多少次100它都是同一个对象引用计数永远不会归零因为解释器守着它们。字符串也有类似情况部分短字符串会被内部驻留interned同样的字符串字面量会不会指向同一个对象要看解释器策略。这个细节直接影响了我们调试时的直觉a 100 b 100 print(a is b) # True指向同一个缓存对象但a 257; b 257时用is比较结果通常是False。很多人刚学Python时被is和的区别弄晕根源其实就在驻留机制上。GC层面这不算什么事但它提醒我们不要指望所有“不再使用”的对象都会被立即回收CPython自己养了一批常驻对象。2.3 del之后内存没降不是泄漏是分配器还在手里这是排查线上问题时最容易产生误解的地方。假设你跑了一段脚本删掉了一堆大列表然后用psutil看进程RSS会发现内存占用几乎没降。你可能会怀疑是GC失效了其实问题不在GC而在底层内存分配器。CPython在释放小对象时并不会每次把内存还给操作系统而是把空闲块放入内存池复用。这样做是为了避免频繁的系统调用和内存碎片化。对象头虽然释放了但内存页还握在进程手里等后续新的对象分配时直接复用。真实情况是Python对象层的“垃圾”确实已经被回收了你看到的RSS下降仅仅是“带宽不够”。区分“对象被回收”和“内存还给OS”这两件事对判断内存问题方向很重要。后面第6节我会给一个系统性的排查路径。2.4 引用计数不能被关闭带来的宏观影响CPython引用计数是架构级的无法像JVM那样选择不同的GC算法。这意味着即使你手动调优gc模块也改变不了“每个对象引用归零立即释放”的既定路数。你真正能调整的只是“循环引用多久被扫一次、怎么扫”而已。所以当我们讨论Python内存调优时一定要搞清楚自己能控制什么、不能控制什么。禁用gc模块不会让引用计数失效反复调用gc.collect()也不会让非循环的对象死得更快。制定优化方案前先把这个心智模型建立起来否则后面每一步都可能白忙。3. 循环引用是谁制造出来的典型代码模式循环引用不是罕见例外而是几乎每个大型项目里都会出现的结构。下面这几个场景是我在真实业务代码中见过最多的情况。3.1 自引用和互相引用最基础的循环结构最简单的是自引用class SelfRef: def __init__(self): self.me self x SelfRef() del x这里x.me指向自身del x之后引用计数不是0而是1。对象成了一个孤岛唯一引用它的是自己。这个例子看似刻意但在ORM关联、树结构、图数据结构里低头可见。双向链表和树节点的父子关系是另一个典型class TreeNode: def __init__(self, name): self.name name self.children [] self.parent None root TreeNode(root) child TreeNode(child) root.children.append(child) child.parent root del root del child删除root和child后两个节点依然互相持有GC不扫的话它们会一直躺在内存里。这种结构在业务里很常见文件系统目录树、组织架构树、菜单树。如果你只删了前端暴露的根节点忘了清理子节点的parent反向引用一定会有残留。3.2 闭包和回调隐蔽的循环引用制造机闭包制造循环引用的方式更隐蔽。看这个例子def outer(): class Obj: pass obj Obj() def inner(): return obj # 闭包捕获了 obj obj.inner inner # obj 又持有 inner return obj item outer() del item这里obj持有inner函数inner的闭包作用域又持有obj两者形成环。你没有写任何parent/child这种明显的引用关系但用gc.get_objects()查的时候能发现这一公一私两个对象都还站在GC的待扫描队列里。闭包循环常见于事件回调、装饰器生成的包装函数、局部函数捕获循环变量再被对象持有的场景。调试这种问题时先看看“谁持有了谁能到达谁”不要急着怀疑GC没干活。3.3 为什么循环引用只能通过“从根遍历”才能找出来引用计数之所以处理不了循环引用是因为它只看到“上一个持有者”看不到“整体是否还有出口”。循环里的每个对象都觉得自己被持有但实际上整个引用图已经没有一条路能通向它们。分代回收的思路是换一个角度从根集合globals()、locals()、栈帧、模块、__main__等出发把所有能到达的对象标为“存活”遍历结束后没被标上的就是垃圾。这个算法叫标记-清除mark-sweep它能识别循环引用但代价是要遍历整个引用图所以CPython引入了分代策略来降低遍历频率也就是第4节要讲的调度逻辑。4. 分代回收的调度算法从0代到2代阈值不是拍脑袋定的4.1 为什么非得分代大部分对象的生命周期都很短。你在循环里创建的临时列表、临时字典一批代码执行完就该没了。如果GC每次都全量扫描所有对象绝大多数时间都在反复检查这些“注定短命”的临时对象浪费极大。分代回收的基本假设是一个对象活过得越久它越可能继续活下去。新创建的对象放进0代活过一轮回收没被清掉的晋升到1代再活过一轮晋升到2代。0代扫描频率最高2代扫描频率最低。这样一来GC可以把绝大部分精力集中在新对象聚集的区域老对象那边偶尔看一眼就行。4.2 阈值和晋升的具体逻辑gc模块默认的阈值是import gc print(gc.get_threshold()) # (700, 10, 10)含义是0代当“已分配对象数 - 已释放对象数”超过700时触发一次0代回收1代0代回收每发生10次触发一次1代回收2代1代回收每发生10次触发一次2代回收注意这里不是“每分配700个对象就扫描一次”而是跟踪净增量。这个差异很关键如果大量对象被分配然后又立即释放净增量可能一直不高GC就不会频繁触发。可以用gc.get_count()观察当前代际的最新计数这个函数返回元组(0代计数, 1代计数, 2代计数)对应各代距上次回收的累积状态。import gc print(gc.get_count()) for i in range(2000): _ [object() for _ in range(200)] print(gc.get_count())实测中跑完这段0代计数大概率已经超过700下次分配动作就会把0代回收拉起来。如果你从没主动调用过gc.collect()也能感受到GC在你不知道的时候跑了多少次。4.3 哪些对象进GC跟踪哪些永远不进去分代GC只跟踪一种东西可能形成循环引用的容器对象。对于int、str这类不可变基础对象它们不可能引用别人也就不可能参与循环引用因此默认不被跟踪。你可以用gc.is_tracked()验证import gc print(gc.is_tracked([])) # True print(gc.is_tracked({})) # True print(gc.is_tracked(123)) # False print(gc.is_tracked(abc)) # False这个细节在分析gc.get_objects()结果时特别重要。你要统计“当前GC世界里有哪些对象”看到的其实是“被跟踪的容器和自定义对象”而不是所有内存。很多字符串和数字占着大量内存但根本不会出现在gc.get_objects()里。4.4 用调试输出看GC真实运行想亲眼看到GC什么时候跑、跑一次回收多少对象可以打开调试输出import gc gc.set_debug(gc.DEBUG_STATS) # 下面这段会促使GC触发 for i in range(3000): d {key: [1, 2, 3]} gc.set_debug(0)开启后控制台会输出类似gc: objects in each generation: 1122, 34, 0、gc: collected 1087、gc: uncollectable 0、gc: 0.023s这样的信息。这在实际调优中很有用能让你直观看到GC耗时不均匀地出现在哪个阶段而不是拿秒表感觉性能波动。5.del、弱引用和回收的边界条件5.1 终结器为什么会拖累回收__del__终结器是Python对象的一个特殊方法引用计数归零或分代GC准备回收对象时会尝试调用它。但当一个不可达循环引用里混入了带有__del__的对象情况会变得微妙——解释器无法确定这些对象的清理顺序不知道该先调谁的终结器于是干脆把这些对象放入gc.garbage列表不再自动回收。import gc class SelfRefWithDel: def __init__(self): self.me self def __del__(self): raise RuntimeError(unreachable) x SelfRefWithDel() del x gc.collect() print(gc.garbage) # 会把x这个对象留在列表里更准确地说CPython从3.4版本开始PEP 442改进了终结器的处理逻辑如果对象是简单循环引用的一部分且对象本身有__del__它会被移动到gc.garbage中等待你手动处理。如果你没有在业务代码里清理gc.garbage这些对象就可能一直存活到进程退出。现代Python代码的正确姿势是尽量不要依赖__del__做资源清理。文件句柄、数据库连接、锁的释放应该走with语句和contextlib.contextmanager让生命周期由代码块显式控制。如果确实要在对象回收时做点事情可以考虑weakref.finalize它比__del__可控得多。5.2 weakref打破循环引用的轻量级武器弱引用weakref的设计目标就是“引用但不影响对象存活”。它不对对象的引用计数产生影响被引用对象回收之后通过弱引用取到的值变成None。import weakref class Cache(dict): pass cache Cache() data (big, object) ref weakref.ref(data) print(ref()) # 取到对象 del data print(ref()) # None对象已经被回收在业务系统里弱引用最常见的用途是避免“缓存对象反过来被缓存引用拖死”。比如你要实现一个观察者模式发布者保存一堆订阅者列表如果发布者对订阅者使用强引用订阅者即使业务上已经不需要了也会因为被发布者持有而一直活着。改成弱引用列表之后订阅者没了列表里自然只剩None。5.3 异常对象和栈帧很容易被忽略的伪回收陷阱except块里的异常对象会在异常处理结束后仍然被局部变量持有。而异常对象的__traceback__属性会引用整个栈帧栈帧里又有局部变量表局部变量又可能引用其他对象形成一条很长的存活链。常见的坑是这个def risky(): try: 1 / 0 except Exception as e: log(e) # 日志框架可能还持有 traceback # 这里如果不显式 del ee 会一直活到函数结束 # 并且携带完整栈帧区域内的局部变量全部存活处理很长事务的批处理脚本里这种“异常对象 栈帧”的引用链会在不知不觉中把大量中间结果钉在内存里。我的习惯是在except块末尾对临时异常变量做e None或者用with把可能出错的操作包起来让异常变量尽快离开作用域。6. 别把“内存不降”都赖给GC版本差异和真正排查路径6.1 CPython版本演进中GC的变化如果你还在拿3.8时代的经验分析3.12的服务很可能得出错误的结论。3.10到3.12之间CPython内部的内存管理和GC做了几次重要调整3.11优化了垃圾回收的标记过程单轮GC的暂停时间更短3.12对对象分配器做了大规模重构每个对象的分配逻辑更细粒度内存布局更紧凑也让一部分曾经属于GC管理的元数据被折叠进了对象头。这些变化带来的直接结果是不同版本下“RSS不降”的原因可能不一样。老版本里很常见的“arena没归还OS”问题在新版本里表现模式也变了。升级Python大版本后内存曲线突然变好或变差都是正常现象不要直接归因到业务代码。6.2 三层排查Python对象层、进程内存层、C扩展层我见过太多人一看到RSS上涨就开始调GC参数结果调了半天真正问题在于C扩展没有释放句柄或者tracemalloc压根看不到那部分内存。排查CPU或内存问题先分三层看。第一层Python对象层。用tracemalloc定位到底哪些Python对象在消耗内存import tracemalloc tracemalloc.start() # 跑一段要排查的业务代码 # ... snap tracemalloc.take_snapshot() for stat in snap.statistics(lineno)[:20]: print(stat)tracemalloc按文件、行号、调用栈把分配记录列出来能很快找到“就是个列表没清”或者“重复创建了同一个大对象”这类问题。第二层GC对象统计层。用gc.get_objects()按类型计数看GC世界里是否有异常的大对象聚集import gc from collections import Counter gc.collect() obj_types Counter(type(o).__name__ for o in gc.get_objects()) for name, count in obj_types.most_common(20): print(name, count)注意这个统计只覆盖被GC跟踪的容器对象所以更适合回答“有没有大量自定义对象没被回收”这种问题而不是“总内存去哪了”。第三层进程内存层。用psutil观察进程的RSS/VMS曲线import psutil import os proc psutil.Process(os.getpid()) print(RSS:, proc.memory_info().rss / 1024 / 1024, MB)如果这一层看到内存持续上涨而第一层显示Python对象总量稳定那问题大概率不在Python GC的控制范围内而是C扩展比如numpy的底层缓冲区、opencv的图像帧、lxml的解析树在独自占用内存。这类内存在tracemalloc和gc里都看不到只能靠进程内存观测配合扩展模块的显式释放逻辑来定位。6.3 一个可以套用的排查顺序我遇到“内存只升不降”的问题时常规动作是这样先gc.collect()跑一次完整回收看看gc.garbage是否为空用tracemalloc跑一段真实热点路径找出Python对象层的大块头用psutil记录半小时RSS曲线观察上涨节奏对照步骤2的结果RSS上涨和Python对象增长是否同步如果不同步排查C扩展资源、线程栈、网络连接等非Python对象层这套顺序能帮你把“GC不干活”和“根本不是GC的事”分开避免瞎调参数。7. 性能优化什么时候该自己接管GC7.1 默认GC配置其实很保守绝大多数业务代码不需要动GC参数。(700, 10, 10)这个默认配置对大多数应用足够温和GC暂停时间通常远小于一次网络IO。但有几类场景确实值得主动干预批量创建临时对象的长任务、对延迟抖动敏感的实时服务、驻留内存型且能预判生命周期的服务端缓存。7.2 实测中的一组调整我曾经在一个批量数据处理脚本里遇到过一个典型问题循环里每次迭代都在创建临时列表和字典0代回收被频繁触发GC时间占了总运行时间的百分之十几。调整的第一步是把0代阈值从700提到5000减少0代回收的触发频率import gc gc.set_threshold(5000, 20, 20) # 跑完业务后手动完整回收 gc.collect()阈值调大后单次0代回收的扫描范围会更大但触发次数明显下降。批量任务这种“跑完就走”的场景收益非常明显。代价是如果对象寿命都特别长0代里堆积的对象多了晋升1代的压力会变大所以阈值不是越大越好要靠实际数据找平衡点。如果希望彻底避免批处理过程中的GC暂停也可以临时禁用GC结束后再开启并手动回收import gc gc.disable() try: do_heavy_task() finally: gc.enable() gc.collect()但这里必须充分确认业务代码里没有循环引用积累。如果你disable之后又不collect存在大量互相持有的临时对象内存会直接失控。这个操作属于“有经验后再用”的招不建议新手一上来就全局禁用GC。7.3 另一个实用工具gc.freeze()gc.freeze()是Python 3.7引入的作用是把当前所有存活对象冻结不让它们再参与后续GC扫描。这个工具很契合“服务启动后加载一堆不可变配置、缓存数据”的场景。服务启动完成后这些对象本来就会活到进程结束与其让GC反复扫描它们不如一次性冻结掉降低GC的全局负担。import gc # 启动阶段加载大量配置、预缓存数据 load_config() load_cache() gc.freeze()注意freeze()只冻结“调用时已经存活”的对象之后新建的对象仍然正常参与GC。如果业务里有“启动后不断创建新对象”的逻辑冻结不会影响它们。7.4 调整GC参数要看的指标不管哪种调法落地之后都要用数据验证不要凭感觉。我建议至少盯三个指标GC触发次数、单次GC耗时、进程RSS曲线。触发次数和耗时可以用gc.set_debug(gc.DEBUG_STATS)看到RSS曲线用psutil记录。如果调完之后GC触发少了一半但RSS上涨速度反而更快那说明回收能力跟不上分配节奏需要回退阈值或改成手动collect。不过我对这类调优的真实体会是多数项目的内存问题根源不在GC机制而在对象生命周期设计。该用with管资源的地方用了__del__该用弱引用的缓存用了强引用该清空的全局列表在异常路径上漏了清理。GC参数调整只是把问题往后推而不是解决它。先说结论CPython这种“引用计数实时回收 分代GC兜底循环引用”的架构决定了摸清对象之间的引用关系比调GC阈值重要得多。你看到的每一个“内存不降”都值得先追问一句这块内存在哪一层是引用计数没归零、分代GC没来得及扫、还是底层分配器还给OS太慢用tracemalloc、gc.get_objects()、psutil三层对照一圈答案通常会自己浮出来。至于gc.disable()、gc.freeze()这些操作我只会在充分理解业务对象生命周期之后再动手而且一定会配套监控毕竟Python的GC机制虽然古早但它挡住的远程事故比它能带来的性能收益更值得看重。
返回列表