什么情况下Python代码容易出现内存泄漏?为了便于分析这个问题,先来看下Python的内存回收策略。
内存回收策略
Python的内存管理简而言之就是:引用计数 + gc。一个Python对象内存被回收存在两个时机,
- 引用计数变为0
当对象引用计数变为0时,会立即进行回收。引用计数无法处理存在循环引用的情况。陷入循环应用的对象们需要通过gc进行回收。
- gc触发
gc触发时会去回收那些无法从root object访问到的对象。在Python下,gc可以认为是专门用来处理循环引用的情况,作为引用计数的一个补充。
内存泄漏分析
在认清内存回收策略之后,反过来思考就能够发现内存泄漏的可能场景了。
引用计数无法降到0的情况
引用直接传递造成的泄漏
一个对象的引用计数怎么会无法降到0呢,最可能的一个原因是该对象的引用因为种种意想不到的情况被外界持有了,且这个持有信息没有得到有效清理。考虑这种情况的构成要素,
- 长期存活的外部对象
- 通过强引用方式进行关联
- 逻辑异常导致清理失败
那些长期存活的外部对象在实现上很多时候是必不可少的,逻辑异常又只能尽量避免却难以根绝,那么可以考虑在引用传递方式上做文章。之前在Python弱引用的使用与注意事项里进行过分析,在合适的场景下选择weakref可以有效的降低这种问题出现的概率。
lambda造成的引用传递
除了显示的直接传递对象引用让外界进行管理之外,在外部注册回调函数也可能引起泄漏,比如,
class Manager(object):
def __init__(self):
self.cb_dict = {}
def do_something(self, key, cb):
self.cb_dict[key] = cb
# ...
manager = Manager()
class Foo(object):
def foo(self):
manager.do_something('foo', lambda: self.complete_something())
def complete_something(self):
pass
如果因为某些异常,任务没有完成,manager中持有的cb没有得到调用与释放,那么cb关联的Foo对象也就被回调所引用着而得不到释放了。
特别在一些需要异步处理的逻辑中,回调函数无可避免。最好从逻辑结构与代码健壮性上去考虑来规避泄漏,不过也还有个折中方法:让回调函数不持有原对象的强引用。一个方法是通过组合weakref与占位对象来达到既避免强引用传递又不会在原对象销毁后抛出ReferenceError: weakly-referenced object no longer exists
。
weakref的不当使用
weakref.proxy
在创建时候可以设置一个参数用于在对象被销毁时进行回调,这个回调弄得不注意了,也可能导致泄漏。
import weakref
class Manager(object):
def __init__(self):
self.objects = {}
def add(self, obj):
self.objects[obj.id] = weakref.proxy(obj, lambda _: self.remove(obj.id))
def remove(self, key):
self.objects.pop(key, None)
上述代码的愚蠢之处在于在回调的lambda函数中隐式泄漏了obj引用。在使用lambda的时候需要小心注意。
defaultdict的默认行为
一个简单的示例,
from collections import defaultdict
class Manager(object):
def __init__(self):
self.objects = defaultdict(set)
def get_count(self, key):
return len(self.objects[key])
manager = Manager()
def foobar():
for i in xrange(1000000):
manager.get_count(i)
foobar()
print manager.objects
运行上述代码会发现objects里面存在大量元素。问题的根源来自,
len(self.objects[key])
当key不存在的时候defaultdict会默认创建一个条目。因此当对大量对象进行检查的时候对象就这么创建了,万一不巧defaultdict实例是一个需要长期存活的对象,那泄漏就这么发生了。
其它
引用计数不能为0的问题根源在于那些长期存活的对象,这可能是自有代码中实现的,还有就是语言特性或标准库里面就含有的。比如,
- 函数默认参数
def foobar(tmp=[]):
tmp.append("foo")
print len(tmp)
for i in xrange(100):
print foobar()
- sys.path
def foobar():
sys.path.append('/path/to/xxx')
for i in xrange(100):
print foobar()
gc未能触发的情况
gc未能触发的可能原因有,
-
gc.set_threshold参数的错误设置
-
关闭gc之后未重启gc
当希望手动去控制gc时,就可能去调整gc的参数或是干脆关闭gc,手动来调用gc.collect。在这种情况下,如果异常错误等打断了手动触发的流程,那就会导致泄漏。这个就需要依靠加强相应代码的健壮性来保证了。
不支持循环引用的情况
使用Python C/C++ extension时需要注意循环引用的处理,Supporting Cyclic Garbage Collection里说了在extension中定义的Python类型需要设置Py_TPFLAGS_HAVE_GC标记。否则gc是无法处理循环引用的,只能等待引用计数为0才会回收,处理不好则非常容易泄露。
内存泄漏排查工具
就个人经验而言objgraph、以及Python标准库中的gc模块是最有效的工具。
objgraph.show_most_common_types
、objgraph.by_type
、objgraph.find_backref_chain
、gc.get_referrers
。这几个是最为有用的接口。