Python 内存泄漏

本文最后更新于:2022年9月1日 下午

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 = {}
# 默认对象引用数量为 2
print(sys.getrefcount(test)) # 2
# 为该对象建立引用
quo = test
# 添加引用后,二者引用数量为 3
print(sys.getrefcount(quo)) # 3
print(sys.getrefcount(test)) # 3
# 删除引用
del quo
# 删除引用后,引用数变回 2
print(sys.getrefcount(test)) # 2
# 重新添加相同的引用
quo = test
# 和之前一样,引用数变为 3
print(sys.getrefcount(quo)) # 3
print(sys.getrefcount(test)) # 3
# 创建新对象(空列表) 覆盖原始 test 对象(空字典)
test = []
# quo 保持对空字典的引用,新对象仅有自己的引用,因此二者都为 2
print(sys.getrefcount(quo)) # 2
print(sys.getrefcount(test)) # 2
print(test, quo) # [] {}
pass
  • 应用数量随着引用情况的变化而变化,可以查看变量的引用数是否清空来调试内存泄漏的情况

objgraph

  • objgraph 是一个用于诊断内存问题的工具,可以通过该工具打印对象数量,以此观察内存变化与对象数量的关系。

  • 安装工具

    1
    pip install 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 使用标记清除算法回收垃圾

1
2
3
import gc
# 强制进行垃圾回收
gc.collect()
  • 弊端就是这个函数比较慢

参考资料


Python 内存泄漏
https://www.zywvvd.com/notes/coding/python/python-mem-leak/python-mem-leak/
作者
Yiwei Zhang
发布于
2022年5月12日
许可协议