本文最后更新于:2024年5月7日 下午
python 自带内存回收机制,但时不时也会发生内存泄漏的问题,本文记录 Python 内存泄漏相关内容。
内存泄漏
程序运行时都需要在内存中申请资源用于存放变量,python 在处理内存中的变量时会调用垃圾回收机制,会留心那些永远不会被引用的变量并及时回收变量,删除并释放相关资源。
- Python 会为变量维护
引用记数器
,这是 Python 垃圾回收机制的基础,如果一个对象的引用数量不为 0 那么是不会被垃圾回收的;
- 因此如果在程序中恰好有方法造成了循环引用或通过某种方式使得引用数量无法降至0,则变量无法被回收, 在批量处理大量任务时内存占用便会不断提升
- 内存泄漏最直接的现象就是 Python 占用的内存量不断增加,直至内存溢出
问题复现
1 2 3 4 5 6 7 8 9 10 11 12
| from time import sleep import numpy as np import tqdm
if __name__ == '__main__': mem_list = [] for _ in tqdm.tqdm(range(10)): huge_mem = np.random.random([1000, 1000, 100]) mem_list.append(huge_mem) sleep(3) pass
|
调试手段
引用数量
- 可以通过
sys.getrefcount
函数得到对象引用数量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| import sys import numpy as np
if __name__ == '__main__':
test = {} print(sys.getrefcount(test)) quo = test print(sys.getrefcount(quo)) print(sys.getrefcount(test)) del quo print(sys.getrefcount(test)) quo = test print(sys.getrefcount(quo)) print(sys.getrefcount(test)) test = [] print(sys.getrefcount(quo)) print(sys.getrefcount(test)) print(test, quo) pass
|
- 应用数量随着引用情况的变化而变化,可以查看变量的引用数是否清空来调试内存泄漏的情况
objgraph
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| import objgraph
if __name__ == '__main__': test_list = [] for _ in range(10): test_list.append([]) objgraph.show_most_common_types(limit=7) print()
--> function 11180 dict 6615 tuple 4385 wrapper_descriptor 2924 list 2643 weakref 2377 member_descriptor 1950
function 11180 dict 6615 tuple 4398 wrapper_descriptor 2924 list 2644 weakref 2377 member_descriptor 1950
function 11180 dict 6615 tuple 4398 wrapper_descriptor 2924 list 2645 weakref 2377 member_descriptor 1950
function 11180 dict 6615 tuple 4398 wrapper_descriptor 2924 list 2646 weakref 2377 member_descriptor 1950
function 11180 dict 6615 tuple 4398 wrapper_descriptor 2924 list 2647 weakref 2377 member_descriptor 1950
...
|
示例中不断增加 list
对象,在计数器中可以看到仅 list
对象不断递增
弱引用
- 影响 Python 垃圾回收的核心问题在于对象引用,而Python 内置了一种特殊的引用,该引用不会增加引用数,可以作为垃圾回收良好的技术
- 详细介绍移步 Python 弱引用 查看
循环引用
大多数内存爆炸增长都是由于将变量存在python 内置可变容器中导致的,比较容易排查,一种更加隐蔽的情况为循环引用
问题复现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| import sys import numpy as np
class MemLeak: def __init__(self, name): self.name = name self.huge_memory = np.random.random([1000, 1000, 100]) self.child = None self.parent = None def __del__(self): print(f"对象 {self.name} 已经被删除。") def ref_count(self): print(f"对象 {self.name} 当前引用数为 {sys.getrefcount(self)}")
if __name__ == '__main__': fir = MemLeak('first') fir.ref_count() fir = []
fir = MemLeak('first') sec = MemLeak('second') fir.ref_count() sec.ref_count()
fir.child = sec sec.parent = fir fir.ref_count() sec.ref_count()
del sec fir.ref_count() del fir
pass
|
- 可以看到,正常情况下,创建的对象被覆盖后,如果引用数归零(line 22),则 python 会自动调用回收机制,并同时清空内存
- 当出现循环引用时,对象的引用数增加了,即使手动 del 对象该对象在内存中也不会被删除,仅会在 python 程序退出时释放内存,也就是循环引用导致了内存泄漏
解决方案
- 我们需要打破循环引用导致的引用数增加,在不改变代码逻辑的情况下,可以将部分 引用转换为弱引用,在保证功能不变的前提下打破计数的引用环,使得对象删除时内存得以正确释放
- 修正代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| import sys import numpy as np import weakref
class MemLeak: def __init__(self, name): self.name = name self.huge_memory = np.random.random([1000, 1000, 100]) self.child = None self.parent = None def __del__(self): print(f"对象 {self.name} 已经被删除。") def ref_count(self): print(f"对象 {self.name} 当前引用数为 {sys.getrefcount(self)}")
if __name__ == '__main__': fir = MemLeak('first') fir.ref_count() fir = []
fir = MemLeak('first') sec = MemLeak('second') fir.ref_count() sec.ref_count()
fir.child = weakref.ref(sec) sec.parent = fir fir.ref_count() sec.ref_count()
del sec fir.ref_count() del fir
pass
|
-
通过调试过程可以看到,在使用弱引用打破计数引用环后,删除对象可以正常释放内存,避免了之前的内存泄漏
-
使用弱引用时需要注意,弱引用不计入引用数量,因此如果需要某个变量存在,必须给他一个正经的引用名称,如果直接用弱引用指向创建的对象,该对象会由于引用数为0而在创建后直接被删除
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import sys import numpy as np import weakref
class MemLeak: def __init__(self, name): self.name = name self.huge_memory = np.random.random([1000, 1000, 100]) self.child = None self.parent = None def __del__(self): print(f"对象 {self.name} 已经被删除。") def ref_count(self): print(f"对象 {self.name} 当前引用数为 {sys.getrefcount(self)}")
if __name__ == '__main__': weakr = weakref.ref(MemLeak('test'))
--> 对象 test 已经被删除。
|
-
然而在实际应用中我们不是很喜欢手动删除所有对象,毕竟不写 C++ 好多年了,是否有方案即解决循环引用难以回收的问题,又可以方便地通过直接覆盖变量的方式方便 python 资源自动回收呢,我在这里做了一个尝试供后人参考
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| import sys import numpy as np import weakref
class MemLeak: def __init__(self, name): self.name = name self.huge_memory = np.random.random([1000, 1000, 100]) self.child = None self.parent = None def __del__(self): print(f"对象 {self.name} 已经被删除。") def ref_count(self): print(f"对象 {self.name} 当前引用数为 {sys.getrefcount(self)}")
if __name__ == '__main__': fir = MemLeak('first') fir.ref_count()
fir.child = MemLeak('second') fir.child.parent = weakref.ref(fir) fir.ref_count() fir = []
pass
--> 对象 first 当前引用数为 4 对象 first 当前引用数为 4 对象 first 已经被删除。 对象 second 已经被删除。
|
- 思路就是根节点中的变量维护其余节点的唯一引用,同时其余节点反向引用时使用
弱引用
,这样根节点和其他节点都仅有一个有效引用,并且其他节点的引用会随着根节点的消失而清空,这样仅通过覆盖根节点即完成了循环引用中所有变量的销毁回收
字典缓存
问题复现
- 字典经常用来保存已经生成的变量,避免使用同一个结果的函数多次生成
- 然而临时结果在无人引用时由于字典的引用会导致保存的对象不会自动释放
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import mtutils import numpy as np import weakref
dict_leak = {}
if __name__ == '__main__':
for index in mtutils.tqdm(range(6)): key = mtutils.create_uuid() value = np.random.random([1000, 1000, 100]) dict_leak[key] = value
|
- 尽管原始创建的变量已经被覆盖销毁,由于在字典中仍保留了他们的引用,因此内存不会被释放
解决方案
- 解决的思路还是从引用数上入手,我们的需求是令那些不再有人能引用到的 value 被清理回收
- 实际上,用字典缓存数据对象的做法很常用,为此 weakref 模块还提供了两种只保存弱引用的字典对象
- weakref.WeakKeyDictionary ,键只保存弱引用的映射类(一旦键不再有强引用,键值对条目将自动消失);
- weakref.WeakValueDictionary ,值只保存弱引用的映射类(一旦值不再有强引用,键值对条目将自动消失);
- 因此,我们的数据缓存字典可以采用
weakref.WeakValueDictionary
来实现,它的接口跟普通字典完全一样。这样我们不用再自行维护弱引用对象,代码逻辑更加简洁明了
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import mtutils import numpy as np import weakref
dict_leak = weakref.WeakValueDictionary()
if __name__ == '__main__':
for index in mtutils.tqdm(range(6)): key = mtutils.create_uuid() value = np.random.random([1000, 1000, 100]) dict_leak[key] = value
|
- 仅更换了字典定义,python 可以正常执行垃圾回收工作
终极方案
- 如果无论如何都难以解决内存泄漏的问题,尝试在代码中加入强制垃圾回收的命令
gc 模块是Python的垃圾收集器模块,gc 使用标记清除算法回收垃圾
参考资料
文章链接:
https://www.zywvvd.com/notes/coding/python/python-mem-leak/python-mem-leak/