Python 高手编程系列一百八十九:分析内存使用

在优化应用程序时可能遇到的另一个问题是内存消耗。如果一个程序开始消耗了很多
的内存,系统就会开始交换,在你的应用程序中可能有一个地方,有太多的对象被创建,
或者你不打算保留的对象由于一些无意的引用仍然保持存活。使用传统的分析可以很容易
检测到该问题,因为消耗很多的内存会引起系统交换,会涉及大量 CPU 的工作,这很容易
被检测到。但有时它不明显,必须分析内存使用情况。
Python 如何处理内存
当你使用 CPython 实现时,内存使用情况可能是 Python 中最难分析的事情。虽然像 C
这样的语言允许你获得任何元素的内存大小,但 Python 永远不会让你知道一个给定的对象消
耗了多少内存。这是由于语言的动态特性,实际上,语言的使用者不能直接访问内存管理器。
内存管理的一些原始细节已经在第 7 章中解释过了。我们已经知道 CPython 使用引用
计数来管理对象分配。这是确定性算法,它确保当对象的引用计数变为 0 时,将会触发对
象释放。尽管是确定性的,这个过程难以手动跟踪而且代码库也非常的复杂。此外,在引
用计数级别上释放对象并不一定意味着解释器释放了实际的进程的堆内存。根据 CPython
解释器的编译标志,系统环境或运行时上下文,内部内存管理层可能会决定留下一些空闲
内存块,用于将来重新分配,而不是完全释放。
CPython 实现中的其他微优化也使预测实际内存使用更加困难。例如,指向同一短字
符串或小整数值的两个变量可能或可能不指向内存中的同一对象实例。
尽管相当吓人,看似复杂,Python 中的内存管理有着非常好的文档(参考 https://docs.
python.org/3/c-api/memory.html)。注意,在大多数情况下,在调试内存问题时,可以忽略前
面提到的微优化。此外,引用计数大致基于一个简单的语句:如果给定的对象不再被引用,
它被删除。换句话说,函数中的所有本地引用都会被解释器删除。
• 离开函数。
• 确保对象不再使用。
因此,保留在内存中的对象如下。
• 全局对象。
• 仍以某种方式引用的对象。
小心参数输入输出的边缘情况。如果在参数中创建了一个对象,并且函数返回该对象,
那么参数引用将仍然存在。如果将其作为默认值使用,可能会导致以下意外结果:

def my_function(argument={}): # 不良实践
… if ‘1’ in argument:
… argument[‘1’] = 2
… argument[‘3’] = 4
… return argument

my_function()
{‘3’: 4}
res = my_function()
res[‘4’] = ‘I am still alive!’
print my_function()
{‘3’: 4, ‘4’: ‘I am still alive!’}
这就是为什么应该总是像这样使用不可变对象,如下:
def my_function(argument=None): # 良好的实践
… if argument is None:
… argument = {} # 每次都是创建新的字典
… if ‘1’ in argument:
… argument[‘1’] = 2
… argument[‘3’] = 4
… return argument

my_function()
{‘3’: 4}
res = my_function()
res[‘4’] = ‘I am still alive!’
print my_function()
{‘3’: 4}
Python 中的引用计数很方便,你无需手动跟踪对象的对象引用,因此你不必手动销毁
它们。虽然这引入了另一个问题,但是开发人员从来不需要清除内存中的实例,如果开发
人员不注意他们使用数据结构的方式,内存可能会以不受控制的方式增长。
通常,消耗内存的情况主要有如下几种。
• 不受控制的缓存。
• 全局注册实例并且不跟踪其使用情况的对象工厂,例如每次调用查询时即时使用的
数据库连接器创建者。
• 未正确结束的线程。
• 使用__del__方法并涉及循环的对象也是内存消费者。在旧版本的 Python(在 3.4版本之前),垃圾回收器不会打破循环,因为它不能确定应该首先删除哪个对象。
因此,这会导致泄漏内存。在大多数情况下不应该使用此方法。
不幸的是,在使用 Python/C API 的 C 扩展中,引用计数的管理必须使用 Py_INCREF()
和 Py_DECREF()宏手动完成。我们在第 7 章中讨论了处理引用计数和引用所有权的注
意事项,所以你应该已经知道这是一个相当棘手的话题,带有各种陷阱。这就是大多数
内存问题是由 C 扩展引起的语音,这些扩展编写的不太合理。
分析内存
在开始寻找 Python 中的内存问题之前,你应该知道 Python 中内存泄漏的本质是相当
特别的。在某些编译语言(如 C 和 C ++)中,内存泄漏多数只是由不再被任何指针引用的
已分配内存块引起的。如果你没有引用内存,你不能释放它,这种情况被称为内存泄漏
(memory leak)。在 Python 中,没有为用户提供底层的内存管理,所以我们宁愿处理泄漏的
引用即对不再需要但未被删除的对象的引用。这会阻止解释器释放资源,但是与 C 中的内
存泄漏的情况不同。当然,总是有 C 扩展的例外情况,但是它们是不同类型的语言,需要
完全不同的工具链,不能从 Python 代码轻松检查。
因此,Python 中的内存问题主要是由意外或计划外的资源获取模式引起的。它很少发
生,这是受到一个真实的 bug 的影响,该 bug 是由处理内存分配和重新分配例程不当造成
的。当使用 Python/C API 编写 C 扩展时,这样的例程仅在 CPython 中可供开发人员使用,
并且你很少会处理它们。因此,Python 中大多数所谓的内存泄漏主要是由软件的过度复杂
性和其组件之间的微小交互造成的,这些交互真的很难跟踪。为了发现和查找软件的这些
缺陷,你需要知道在程序中的实际内存使用情况。
获取有关 Python 解释器控制的对象数量以及它们的实际大小的信息有点棘手。例如,
知道给定对象占用多少字节,这将涉及其所有属性,处理交叉引用,然后总结这一切。如
果你考虑对象彼此相关的方式,这是一个很困难的问题。gc 模块不会为此提供高级函数,
并且需要 Python 在调试模式下编译,从而可以拥有一整套信息。
通常,程序员只是在执行给定操作之后和之前向系统查询其应用程序的内存使用情况。
但是这种测量是一个近似值,并且很大程度上取决于系统级的内存管理方式。例如,使用
Linux 下的 top 命令或 Windows 下的任务管理器,可以检测到明显的内存问题。但这种方
法太费力,并且也难以跟踪错误的代码块。
幸运的是,有几个工具可以抓取内存快照并计算加载对象的数量和大小。但是让我们
谨记,Python 不会轻易释放内存,而倾向于继续持有,以防再次需要。
有一段时间,调试内存问题和在 Python 中使用的最流行的工具之一是 Guppy-PE 及其
Heapy 组件。不幸的是,它似乎已不再维护,并且缺乏对 Python 3 的支持。幸运的是,还有一些其他的选择,并且在某种程度上兼容 Python 3。
• Memprof(http://jmdana.github.io/memprof/):该工具声明支持 Python 2.6,2.7,3.1,3.2
和 3.3 以及一些符合 POSIX 的系统(Mac OS X 和 Linux)。
• memory_profiler(https://pypi.python.org/pypi/memory_profiler):该工具声明支持与
Memprof 相同的 Python 版本和系统。
• Pympler(http://pythonhosted.org/Pympler/):该库声明支持 Python 2.5,2.6,2.7,3.1,3.2,3.3
和 3.4,并且与操作系统无关。
请注意,上述信息纯粹基于最新分发包中的特性包的分类器。在本书写作之后,这可
能发生改变。尽管如此,当前有一个包支持最广泛的 Python 版本,并且在 Python 3.5 下也
可以完美无缺地工作。它就是 objgraph。它的 API 似乎有点笨拙,并且具有非常有限的
功能集。 但它工作,在需要它的地方做得很好,并且很容易使用。内存工具无需永久添加
到生产代码中,所以这个工具不需要多漂亮。由于其在操作系统独立性上对 Python 版本的
广泛支持,所以在讨论内存分析的示例时,我们将仅关注 objgraph。本节中提到的其他
工具也是令人兴奋的软件,但你需要自己研究它们。
objgraph
objgraph(参考 http://mg.pov.lt/objgraph/)是一个简单的工具,用于创建对象引用的
图表,可以用于在 Python 中寻找内存泄漏。它在 PyPI 上可用,但它不是一个完全独立的
工具,需要 Graphviz 才能创建内存使用图。对于像 Mac OS X 或 Linux 这样的开发者友
好系统,你可以使用首选的系统软件包管理器轻松获取它。对于 Windows,你需要从项目
页面下载 Graphviz 安装程序(参考 http://www.graphviz.org/)并手动安装。
objgraph 提供了多个实用程序,可以列出并且打印有关内存使用情况和对象计数的
各种统计信息。在解释器会话中使用此类实用程序的示例如下:
import objgraph
objgraph.show_most_common_types()
function 1910
dict 1003
wrapper_descriptor 989
tuple 837
weakref 742
method_descriptor 683
builtin_function_or_method 666
getset_descriptor 338
set 323
member_descriptor 305
objgraph.count(‘list’)
266
objgraph.typestats(objgraph.get_leaking_objects())
{‘Gt’: 1, ‘AugLoad’: 1, ‘GtE’: 1, ‘Pow’: 1, ‘tuple’: 2, ‘AugStore’: 1,
‘Store’: 1, ‘Or’: 1, ‘IsNot’: 1, ‘RecursionError’: 1, ‘Div’: 1, ‘LShift’:
1, ‘Mod’: 1, ‘Add’: 1, ‘Invert’: 1, ‘weakref’: 1, ‘Not’: 1, ‘Sub’: 1,
‘In’: 1, ‘NotIn’: 1, ‘Load’: 1, ‘NotEq’: 1, ‘BitAnd’: 1, ‘FloorDiv’:
1, ‘Is’: 1, ‘RShift’: 1, ‘MatMult’: 1, ‘Eq’: 1, ‘Lt’: 1, ‘dict’: 341,
‘list’: 7, ‘Param’: 1, ‘USub’: 1, ‘BitOr’: 1, ‘BitXor’: 1, ‘And’: 1,
‘Del’: 1, ‘UAdd’: 1, ‘Mult’: 1, ‘LtE’: 1}
如前所述,objgraph 可以创建内存使用模式和交叉引用的图表,交叉引用连接了给
定命名空间中的所有对象。该库中最有用的图表实用程序是 objgraph.show_refs()和
objgraph.show_backrefs()。它们都接受对被检查对象的引用,并使用 Graphviz
包将图表图像保存到文件。这样的图的示例在图 11-2 和图 11-3 中示出。
以下是用于创建这些图表的代码:
import objgraph
def example():
x = []
y = [x, [x], dict(x=x)]
objgraph.show_refs(
(x, y),
filename=‘show_refs.png’,
refcounts=True
)
objgraph.show_backrefs(
(x, y),
filename=‘show_backrefs.png’,
refcounts=True
)
if name == “main”:
example()
图 11-2 显示了 x 和 y 对象保存的所有引用。从上到下和从左到右,它提供了 4 个对象:
• y = [x, [x], dict(x=x)] 列表实例。
• dict(x=x) 字典实例。
• [x] 列表实例。
• x = [] 列表实例。
为了展示如何在实践中使用 objgraph,让我们回顾一些实际的例子。正如我们在本
书中已经提到过几次的,CPython 有自己的垃圾收集器,它独立存在于引用计数方法。它
不用于通用内存管理,而只用于解决循环引用的问题。在许多情况下,对象可能以一种方式
互相引用,这时使用基于跟踪引用的数量的简单技术无法删除它们。这里是最简单的例子:
x = []
y = [x]
x.append(y)
当这种周期中的至少一个对象具有定义的自定义__del__()方法时,真正的问题开始了。
它是一个自定义的释放处理程序,当对象的引用计数最终为零时将调用该方法。它可以执行任
何 Python 代码,所以它也可以创建特征对象的新引用。这是导致下述问题的原因,如果至少
有一个对象提供了自定义的__del__()方法实现,Python 3.4 版本之前的垃圾回收器就不能中
断循环引用。PEP 442 向 Python 引入了对象安全终结,从 Python 3.4 开始,它已经是标准的一
部分。无论如何,对包来说,这可能仍然是一个问题,这些包担心向后兼容性和目标广泛的
Python 解释器版本。以下代码段显示了在不同 Python 版本中循环垃圾回收器的行为差异:
import gc
import platform
import objgraph
class WithDel(list):
“”" 列出子类中的自定义__del__实现 “”"
def del(self):
pass
def main():
x = WithDel()
y = []
z = []
x.append(y)
y.append(z)
z.append(x)
del x, y, z
print(“unreachable prior collection: %s” % gc.collect())
print(“unreachable after collection: %s” % len(gc.garbage))
print(“WithDel objects count: %s” %
objgraph.count(‘WithDel’))
if name == “main”:
print(“Python version: %s” % platform.python_version())
print()
main()
当在 Python 3.3 下执行时,上述代码的输出表明,较老版本的 Python 中的循环垃圾收
集器不能收集具有__del__()方法定义的对象,如下所示:
$ python3.3 with_del.py
Python version: 3.3.5
unreachable prior collection: 3
unreachable after collection: 1
WithDel objects count: 1
使用较新版本的 Python,垃圾收集器可以安全地处理终结对象,即使它们定义了
del()方法,如下所示:
$ python3.5 with_del.py
Python version: 3.5.1
unreachable prior collection: 3
unreachable after collection: 0
WithDel objects count: 0
虽然在最新的 Python 版本中自定义终结不再那么棘手,但它仍然对需要在不同环境下
工作的应用程序造成了一个问题。如前所述,objgraph.show_refs()和 objgraph.
show_backrefs()函数允许你轻松地发现有问题的类实例。例如,我们可以很容易地修
改 main()函数,以显示对 WithDel 实例的所有反向引用,以便查看是否存在泄漏的资源,
如下所示:
def main():
x = WithDel()
y = []
z = []
x.append(y)
y.append(z)
z.append(x)
del x, y, z
print(“unreachable prior collection: %s” % gc.collect())
print(“unreachable after collection: %s” % len(gc.garbage))
print(“WithDel objects count: %s” %
objgraph.count(‘WithDel’))
objgraph.show_backrefs(
objgraph.by_type(‘WithDel’),
filename=‘after-gc.png’
)
在 Python 3.3 下运行前面的示例将产生一个图(见图 11-5),它显示 gc.collect()
无法成功删除 x,y 和 z 对象实例。此外,objgraph
突出显示所有具有自定义__del__()方法的对
象,以使更容易地发现这些问题。
C 代码内存泄漏
如果 Python 代码看起来完全正常,但是在循
环访问隔离函数时,内存仍然增加,则泄漏可能
位于 C 端。例如,当一个 Py_DECREF 调用丢失
时,就会发生这种情况。
Python 核心代码是相当健壮的,并且对泄漏
也进行了测试。如果你使用具有 C 扩展程序的
软件包,那你应该首先关注它们。因为你将处理
在比 Python 更低的抽象层上进行操作的代码,
所以你需要使用完全不同的工具来解决这些内
存问题。
在 C 中进行内存调试不太容易,因此在深入
扩展内部之前,请确保正确诊断问题的根源。隔
离具有类似于单元测试的代码的可疑包,这是一
个比较常用的做法。
• 为每个 API 单元或者引起内存泄漏的扩
展的可疑的功能编写单独的测试。
• 在一个循环中独立进行任意长时间的测试(每次运行一次测试)。
• 从外部观察哪个测试功能会随着时间增加内存使用。
希望你已经隔离了扩展中导致内存泄漏的部分,并最终可以开始实际的调试。如果你
很幸运,简单地手动检查源代码可能就会得到所需的结果。在许多情况下,问题就像添加
缺少的 Py_DECREF 调用一样简单。然而,在大多数情况下,我们的工作并不是那么简单。
在这种情况下,你需要带一些更强大的工具。Valgrind 是一个著名的通用工具,它可以在
编译代码中处理内存泄漏,每个程序员都应该掌握这个工具。它是一个用于构建动态分析
工具的完整的探测框架。因此,它可能不容易学习和掌握,但你应该了解一些基础知识。

  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值