多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Python del与垃圾回收:为什么删除变量内存却不释放?

Python del与垃圾回收:为什么删除变量内存却不释放? 先说我踩过的一个坑。以前写Python爬虫脚本时每个批次的response和解析出来的DataFrame我都习惯性地del掉结果任务跑到第40个批次内存还是涨到了2GB最后被系统直接kill掉。当时我一度以为del就是Python官方给的“内存释放钥匙”后来翻了不少源码和GC统计才意识到del语句和垃圾回收机制之间隔着一层非常微妙的逻辑。这段时间正好有新手朋友问我是不是多写del就能防止内存泄漏也有做量化策略建模的朋友来问为什么pandas出来的DataFrame明明del了任务管理器里的内存还是没降。这其实暴露了一个很普遍的认知盲区把del当成了内存释放工具却忽视了引用计数、循环引用、分代回收这一整套机制。这篇文章我会把实测经验、源码阅读心得和一些排坑记录整理出来尽量讲清楚del到底做了什么、垃圾回收到底什么时候才真正动手以及日常代码里该怎么配合才不会踩坑。1. del语句的本质很多人第一步就理解错了1.1 del删的是名字不是对象我见过太多人误以为del之后对象就被销毁了。这个理解在简单场景下碰巧能对上但一旦牵扯到多个变量引用同一个对象就会出问题。Python里变量和对象的关系跟C语言的指针有点像但并不完全一样变量更像是一个贴在对象上的标签或者说是存放在命名空间里的一个引用记录。看一段最简单的代码a [1, 2, 3] b a # b和a现在都指向同一个列表对象 del a # 删除名字a print(b) # 输出 [1, 2, 3]对象还活着 print(a) # NameError: name a is not defined执行到del a时真正发生的事情是从当前作用域的命名空间里移除a这个名字同时让底层列表对象的引用计数减一。由于b仍然指向它引用计数没有降到0所以对象毫发无损地继续存在。这个现象很像你在通讯录里删掉一个人的电话号码但这个人本身并没有消失只是你少了一种找到他的方式。一个对象什么时候才被真正释放答案很简单当指向它的引用计数降到0时。del只是减少引用计数的一种手段不是销毁对象本身。后面我会详细讲引用计数这里先建立起这个概念del操作的是名字不是对象的内存。1.2 用sys.getrefcount看看引用计数的变化如果你也想亲手验证引用计数的变化可以用sys.getrefcount来观察。注意这个函数本身会临时增加一次引用计数所以读出来的数值会比你想的多1。import sys a [] print(sys.getrefcount(a)) # 通常是2变量a占一个getrefcount参数占一个 b a print(sys.getrefcount(a)) # 变成3多了一个b的引用 del a # 此时a已经不存在了不能再传a进getrefcount print(sys.getrefcount(b)) # 变成2只剩b和getrefcount参数本身从输出能明显看到del之后那个空列表对象没有被释放因为b还在引用它。等b也del或者函数执行完毕作用域销毁引用计数减到0对象才会被CPython即时回收。这种“删名字不减对象”的行为在函数传参、列表存储对象、闭包捕获变量的时候尤其容易踩坑。比如把一个大的字典塞进某个全局缓存列表即使你del了原始变量只要缓存列表里还有引用内存就是不会降。排查内存问题时第一反应不应该是“我del没del”而应该是“还有谁在引用它”。1.3 循环引用del失效的经典现场如果只是多个变量指向同一个对象del配合引用计数还能解释清楚。真正麻烦的是循环引用。看一个很常见的结构class Node: def __init__(self): self.next None a Node() a.next a # 自己指向自己 del a这里del a执行之后a这个名字没了但Node对象内部还持有对自己的引用self.next引用计数始终是1永远降不到0。对象就这样变成了垃圾回收机制面前的“死角”。更常见的是两个对象互相引用比如双向链表、父子结构、观察者模式class Parent: def __init__(self): self.child None class Child: def __init__(self, parent): self.parent parent p Parent() c Child(p) p.child c del p del c执行完两个del外部已经没有任何名字能触达这对对象了但它们彼此引用着对方引用计数都还停留在1。从业务逻辑的角度看它们已经是垃圾但从引用计数的角度看它们还“活得好好的”。这就是为什么CPython在引用计数之外还要再引入一套垃圾回收机制来处理循环引用。提到这里我想特别强调一个容易混淆的点del不负责回收垃圾回收器才负责回收。del只是把对象交给GC判断的触发条件之一。如果对象没有循环引用del之后引用计数归零立即释放如果对象有循环引用del之后还要等分代回收器扫描到那一代才能把它识别为垃圾并清理掉。2. Python垃圾回收机制引用计数与分代回收怎么配合2.1 引用计数对象生死的直接裁判CPython官方解释器的内存管理核心是引用计数。每个Python对象头部都有一个ob_refcnt字段用来记录当前有多少个引用指向它。赋值给变量、添加到列表、作为参数传入函数、设置成对象属性这些操作都会让引用计数加1变量被del、列表被清空、函数返回后局部变量销毁这些操作会让引用计数减1。当计数归零对象立刻被释放内存归还给Python内存分配器。这个机制最大的优点是及时且直观。普通对象没有循环引用的一旦失去所有引用内存马上释放不需要像Java那样等GC线程跑一圈。我刚才写的示例里del b之后那个列表对象如果确实没有其他引用了sys.getrefcount也传不进去了内存马上可以被复用。它最大的缺点也毋庸置疑循环引用会让计数永远无法归零。另外频繁修改引用计数会产生不小的性能开销所以Python在内部做了很多优化比如某些不可变对象的引用计数会特殊处理。平时写代码时不需要关心这些优化细节但理解引用计数这个底层机制对排查内存问题是必要的。需要明确的是引用计数只负责“把对象计数清到零之后立刻释放”而垃圾回收器负责的是“找出那些计数不为0但已经无法从根对象到达的对象”。这两个工作不是一回事但经常被人混为一谈。2.2 分代回收专门处理循环引用的补充机制说完引用计数再来看分代回收。CPython的垃圾回收器只跟踪“容器对象”比如列表、字典、集合、自定义类实例因为只有这类对象才能互相引用形成环。像整数、字符串这种不可变对象通常不参与循环引用的追踪这里严格说PyLong等也有优化细节但日常不用记太细。它的工作方式是基于分代假设大部分对象存活时间很短朝生暮死能活过几轮GC的对象之后短时间内死掉的可能性也变小。于是CPython把跟踪对象分成三代第0代是新建对象第1代是挺过一轮GC的对象第2代是最老的。默认阈值可以通过gc.get_threshold()查看通常是(700, 10, 10)。第0代新创建的容器对象都会先进这一代。当第0代对象数量超过700GC就触发一次第0代回收。存活下来的对象会被提升不是物理移动而是代际标记到第1代。第1代当第0代被回收10次之后会顺带对第1代做一次回收。第2代当第1代被回收10次之后才会对第2代做一次回收。我经常在脚本里重复创建大量临时对象你可以运行一下gc.get_count()来观察它返回(当前第0代对象数, 当前第1代对象数, 当前第2代对象数)。很多时候你会发现第0代数量很快冲到几百上千而第2代的对象数慢慢累加。那些长期驻留内存的对象进入第2代之后不会频繁被扫描这也是老年代对象可能长期不释放的原因之一。分代回收处理循环引用的大致过程是从根对象出发做一次可达性分析找到哪些对象虽然引用计数不为0但已经没有任何外部路径能到达它们于是把它们标记为垃圾触发它们的__del__并回收内存。这个过程的原始实现可能会触发一些奇怪的副作用在Python 3.4之后已经改进过但理解它的大致模型就够了。2.3 用调试工具看清GC的行为调试GC行为时我常用几个工具和方法。第一是gc.get_threshold()确认阈值有没有被全局调整过。很多第三方库会改动GC阈值比如某些深度学习框架会为了性能把阈值调大如果你在它们的环境中发现内存不释放可以先查这个值。第二是gc.set_debug(gc.DEBUG_STATS)它会在每次GC结束后打印统计信息包括处理了多少对象、耗时多少。调试时开一下能直观看到GC频率和耗时。生产环境不要长期开日志量很大。第三是tracemalloc模块它专门统计Python对象的内存分配来源可以按代码文件、行号来汇总内存。遇到“del之后内存没降”的疑难问题时tracemalloc能告诉你剩下的内存到底是谁分配的、从哪一行分配的。import gc import tracemalloc gc.set_debug(gc.DEBUG_STATS) # 开启GC统计日志 tracemalloc.start() # 这里跑你的业务代码 snapshot tracemalloc.take_snapshot() top_stats snapshot.statistics(lineno) for stat in top_stats[:10]: print(stat)配合这两个工具基本能定位到是GC没触发、循环引用卡在老年代还是一段代码分配了海量小对象。这块内容在线上排查内存问题时非常实用。3. 实战del和垃圾回收的正确配合姿势3.1 该用del的场景和不该用del的场景不需要用del的场景其实更多。函数内部创建的局部变量在函数退出时会自动销毁手动del是画蛇添足。循环里反复创建的小对象下一次迭代时引用自动覆盖也不用手动del。真正的反模式是在每个循环末尾到处写del x、del y既影响可读性又往往帮不上忙。该用del的场景有这几类全局变量或类属性引用了一个很大的临时数据又在某个时间点明确不需要了。比如一个全局配置字典初始化后长期放在内存里但业务上一段时间后确实不再需要此时del可以主动切断引用让引用计数降下来。大型容器对象占内存很高而且会活很久。比如量化回测代码里加载了整段时间序列数据中途换成新的数据帧旧变量已经没用了但它的引用还挂在某个列表或全局字典里。这时del再配合移除容器引用效果明显。循环体内产生了体积巨大的局部对象并且这个循环体是长活对象的一部分比如某个方法很长这个大对象可能会被方法后续代码或闭包引用。与其等函数结束不如在明确用完的时候del降低瞬时内存峰值。举个例子我在写数据批处理脚本时经常这样处理def process_batch(file_path): data load_large_file(file_path) # 几百MB的DataFrame result data.groupby(...).agg(...) # 中间形态已经不需要了主动切断引用降低峰值 del data return result这个del的意义不是“销毁”那个DataFrame而是让它的引用计数尽快从多个引用降下来方便Python将底层内存归还分配器。在内存峰值敏感的批处理任务中有实际价值。3.2 手动gc.collect的时机与代价很多时候del了循环引用对象之后内存不会马上下降因为要等分代回收器触发。于是有同学会手动调用gc.collect()。这个方法确实有用但必须有节制。我觉得值得手动调用的典型时机有三个一段耗时很长的脚本运行完一批任务之后且这批任务产生了大量循环引用对象。比如爬虫抓完一个站点解析出的DOM树互相引用严重主动collect一次能立刻释放大量内存。即将进入对内存很敏感的后续阶段之前。比如训练模型前先清理掉之前缓存过的数据再gc.collect()防止后面内存不足。测试环境证明某个逻辑会产生循环引用并且不想等阈值触发时。最忌讳的是在循环里高频调用gc.collect()。分代回收器要遍历对象引用关系扫描频率越高整体性能越差。我见过一例在一个2万次迭代的量化模拟循环里每次循环都调gc.collect()结果跑完一次要40分钟去掉之后只用了3分钟。这不是循环引用对象太多了而是GC扫描成本太高。如果确实担心循环引用可以用gc.collect(0)只回收年轻代成本低不少但也要谨慎使用。3.3 用weakref和迭代器从根本上避开循环引用与其指望垃圾回收器收拾循环引用不如从根本上减少这种结构。最常用的工具是weakref模块。弱引用的核心特点是它不会让对象的引用计数增加。当对象只有弱引用指向它时对象照样被回收弱引用会自动变成None。import weakref class Node: def __init__(self): self.parent_ref None self.children [] a Node() b Node() b.parent_ref weakref.ref(a) # 弱引用指向a不增加a的引用计数 del a print(b.parent_ref()) # None说明a已经被回收用弱引用重构父子关系、缓存结构能避免环的形成。另一种更贴近日常的替代方案是用迭代器和生成器按需取数据而不是把所有对象都挂在长期存活的容器里。比如爬虫任务里不需要把所有response一次性存进列表再统一处理改成生成器逐条读取、逐条处理天然就减少了长期引用也不需要频繁del。这算是我个人的一个习惯遇到内存问题先想“能不能重构持有方式”再想“能不能del”。弱引用和迭代器往往比手动del更优雅也更不容易漏。4. 常见问题与排查记录4.1 del了内存没降先别怪del不少人写代码时发现明明del了大对象任务管理器的内存占用还是纹丝不动。这里有几种可能我整理成一张排查表现象可能原因处理方向del之后对象还在被其他变量引用别名或容器内仍然持有引用用gc.get_referrers(obj)查引用来源循环引用且GC未触发老年代对象太多回收频率低适当gc.collect()或优化对象结构Python内存池缓存了小块内存小对象释放后内存被分配器复用不还给OS属正常现象看内存复用率而非RSS第三方C扩展占用了内存numpy、pandas底层内存不由Python管理检查底层库的缓存和释放机制操作系统没有真正归还RSS已释放但OS还保留页表用tracemalloc确认Python侧是否下降最常见的“假象”是CPython的内存池机制。Python分配小块内存时并不是每次都直接问操作系统要而是内部维护了一些缓存池。对象释放后内存块进入缓存池后续新对象可以直接复用但不会立刻返还给操作系统。所以任务管理器看到的内存不降不代表内存泄漏可能只是还没被复用。我处理这类问题时的思路很明确tracemalloc显示Python内部的分配是否下降如果下降而RSS不降这是缓存问题如果tracemalloc显示也不降才去查引用关系。4.2 __del__为什么“玩失踪”以及正确的清理姿势__del__是一个析构方法很多人会在里面放释放资源、关闭文件、保存日志等逻辑。但它有个坑垃圾回收器本身就是一个Bug来源。在循环引用场景里如果对象带有__del__方法GC的行为会变得很复杂。Python历史上有过一个著名的限制——循环引用中的__del__不会被调用因为GC无法安全地确定调用顺序。后来Python 3.4引进了PEP 442之后情况得到改善循环引用的对象在大多数情况下还是会调用__del__但触发时机和顺序依然不可控。跨语言交互的场景里这些不确定性更容易波及到C扩展。更微妙的问题是在解释器退出或GC阶段__del__里的代码可能执行到一半它所依赖的全局变量已经变成了None导致崩溃或诡异报错。我见过一个项目在模块的全局字典里缓存了配置对象的__del__会去读这个字典程序退出时GC清理对象发现字典已经没了直接AttributeError。正确的做法是尽量避免在__del__里做复杂清理。文件、连接、锁这类资源应该用with语句或contextlib.closing来管理保证明确、及时地释放from contextlib import closing with closing(open(data.txt)) as f: content f.read() # 这里文件必然已经关闭不依赖GC如果真的要在对象销毁时做些事情更稳妥的方式是注册atexit回调或者提供显式的close()方法让调用方来控制生命周期。总之资源管理靠__del__属于最后手段能不用就不用。4.3 频繁del反而更慢真正的开销在哪有人可能觉得del用得多和性能没关系反正只是个引用计数减一。这句话对了一半del操作本身成本很低近似O(1)。但del引起的连锁反应可能很大。当一个对象的引用计数降到0CPython需要立即回收它回收意味着要释放内部资源比如列表要释放元素数组字典要释放哈希表自定义对象要调用潜在的__del__。如果这个对象又持有其他对象引用这种释放会递归传播。你可以想象成在一个摩天大楼里拆除一根承重梁结果整个楼体结构跟着连锁松动比当初浇筑时还麻烦。更典型的性能杀手是频繁创建和销毁大批量小对象。比如在一个循环里每秒创建10万个字典用完就del内存分配器压力会非常大。Python解释器再快也扛不住这么频繁的malloc/free。遇到这种场景正确思路不是纠结del写没写而是减少对象创建次数复用同一个字典或列表循环里只更新内容。使用array、numpy等连续内存结构代替大量Python小对象。采用批量处理把10万次循环改成100次循环每次处理1000条。我写量化回测代码时也犯过这种错每个时间步都创建新的DataFrame片段循环结束后再del结果耗时高得离谱。后来改成用numpy数组预分配空间内存占用和时间都降了一个量级。这时候del解决不了性能问题只有改变数据结构才能治本。最后说一个我自己的习惯排查内存问题时我很少一上来就到处补del而是先问三个问题这个对象还有谁在引用它的存活时间合不合理能不能用弱引用或迭代器改掉持有方式大部分情况下答案不是“需要del”而是“需要重构引用关系”。根据个人经验Python里的内存管理更像是一个协作体系del负责切断引用引用计数负责即时回收分代回收负责兜底处理循环引用。你只有理解它们各自的边界才能在写爬虫、量化策略或者复杂数据处理脚本时不被内存问题折磨。希望这几年的实战心得对你有帮助。
返回列表