0x00 背景
内存泄漏是程序常见的错误行为之一,在 uwsgi 或者 gunicorn 里可以配置 max-requests 参数,来决定执行多少次请求后,重启 worker 来对抗内存泄漏问题,此外语言还有 gc 机制,在一般情况都不需要考虑内存泄漏问题。 但是不正确的程序写法,在某些场景下会导致内存飙升,内存使用率居高不下,甚至 OOM。因此内存泄漏问题需要引起我们的重视,在出现内存泄漏问题时,及时定位和解决。
那怎么确定一个程序是发生了内存泄漏,而不是正常的程序内存申请占用呢?
在有监控的场景下如 grafana、zabbix 时,可以参看对应机器的内存使用情况,在相对较长的时间内,是否存在内存持续增长的情况。如下图,内存在缓慢持续上涨,说明极有可能发生了内存泄漏问题。
0x01 排查工具
确定了内存泄漏问题后,就需要进一步排查到底是什么问题引起的内存泄漏了。朴素的排查思路是,观察内存上升时,被调用的服务。例如某个接口被调用后,内存增加,在接口调用结束一段时间后内存仍未释放(需要结合 gc 来看),则该接口发生内存泄漏的概率极大。除了定性分析,python 还有不少定量分析的工具,来辅助我们定位内存泄漏问题,主要有以下三类:
- 调试工具的 gdb、pdb
- 查看内置对象数量和占用空间的 objgraph、guppy、pympler
- 逐行/文件统计内存申请占用量的 memory_profiler、tracemalloc
1. gdb、pdb
pdb 模块定义了一个交互式源代码调试器,用于 Python 程序。它支持在源码行间设置(有条件的)断点和单步执行,检视堆栈帧,列出源码列表,以及在任何堆栈帧的上下文中运行任意 Python 代码。它还支持事后调试,可以在程序控制下调用。 gdb 大家都了解不再介绍了,gdb 7+以后支持 python 程序。下面演示下使用 gdb 保存 python 内存堆栈。
1.使用 gdb 监听 python 程序
$ gdb -p 1131
GNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-94.el7
Copyright (C) 2013 Free Software Foundation, Inc.
...
Attaching to process 1131
...
(gdb)
2.获取进程的内存范围
(gdb) ! grep heap /proc/1131/maps
00c7d000-00d94000 rw-p 00000000 00:00 0 [heap]
3.将内存堆栈保存到文件里
(gdb) dump binary memory /tmp/python-heap.bin 0x00c7d000 0x00d94000
之后就可以使用 xxd 等工具建站内存堆栈信息了,也有 strings 这样的用于打印堆栈中所有的字符串的工具。 借助这些工具我们可以查看哪些数据占用的内存过大,但是 python 的内存堆栈分析工具远不如 java 的强大好用,因此常常还要借助下面的分析工具。
xxd python-heap.bin
0000000: 0102 0304 5374 7269 6e67 2044 6174 61aa ....String Data.
0000010: bbcc 0000 0000 0000 0000 0000 0000 0000 ................
0000020: 0000 0000 0000 0000 0000 0000 0000 0000 ................
0000030: 0000 0000 0000 0000 0000 0000 0000 0000 ................
0000040: 0000 0000 0000 0000 0000 0000 0000 0000 ................
0000050: 0000 0000 0000 0000 0000 0000 0000 0000 ................
0000060: 0000 0000 ....
2. objgraph、guppy
objgraph 除了绘制内存对象关系外,提供了两个函数用于统计 python 对象的数量和对象的增长量,例如:
>>> import gc
gc.collect()
>>> import objgraph
>>> objgraph.show_most_common_types()
function 41369
dict 35348
tuple 29144
list 19314
weakref 9980
cell 8595
type 6282
getset_descriptor 4826
set 3707
DeferredAttribute 3150
>>> u2 = User.objects.first() #业务代码
>>> objgraph.show_growth()
dict 35425 +4
list 19328 +2
User 2 +1
AnnotatedValue 2 +1
ModelState 2 +1
guppy 和 objgraph 在内存分析方面的使用差不多,主要看 python 内置对象的数量,唯一不同的是,能显示每种类型占用的内存总量,例如:
>>> from user.models import *
>>> from guppy import hpy
>>> hp = hpy()
>>> before = hp.heap()
>>> u = User.objects.first() #业务代码
>>> after = hp.heap()
>>> mem = after - before
>>> mem
Partition of a set of 2035 objects. Total size = 250202 bytes.
Index Count % Size % Cumulative % Kind (class / dict of class)
0 519 26 56304 23 56304 23 str
1 637 31 51640 21 107944 43 tuple
2 288 14 26946 11 134890 54 bytes
3 21 1 23120 9 158010 63 type
4 141 7 20576 8 178586 71 types.CodeType
5 68 3 19712 8 198298 79 dict (no owner)
6 89 4 12104 5 210402 84 function
7 21 1 9328 4 219730 88 dict of type
8 5 0 5944 2 225674 90 dict of module
9 43 2 4816 2 230490 92 dict of django.db.models.expressions.Col
<34 more rows. Type e.g. '_.more' to view.>
3. memory_profiler、tracemalloc
objgraph、guppy 只能分析有哪些数据类型,而且 python 内置的数据类型,和他们分别占用的内存量。想要具体分析每行代码的内存占用,还得依赖这两个工具 memory_profiler 和 tracemalloc。 memory_profiler 提供了 @profiler 装饰器,能针对具体的函数调用过程,记录每行的内存使用情况,例如:
@profile
def my_func():
a = [1] * (10 ** 6)
b = [2] * (2 * 10 ** 7)
del b
return a
if __name__ == '__main__':
my_func()
输出为
Line # Mem usage Increment Occurences Line Contents
============================================================
3 38.816 MiB 38.816 MiB 1 @profile
4 def my_func():
5 46.492 MiB 7.676 MiB 1 a = [1] * (10 ** 6)
6 199.117 MiB 152.625 MiB 1 b = [2] * (2 * 10 ** 7)
7 46.629 MiB -152.488 MiB 1 del b
8 46.629 MiB 0.000 MiB 1 return a
另外 memory_profiler,提供了 mprof 工具可以监听程序的内存使用量,并绘制内存占用图形,在针对特定场景的内存占用分析时也很有用。 以某个 celery 任务为例,查看从项目启动到结束时,内存的占用量
# 增加入口脚本,执行celery,定时记录内存
mprof run run.py worker -A user -c 1 -Q Es_request_sync
# 查看内存随时间变化图
mprof plot
结果如图
此外 memroy_profiler 还提供了个很实用的功能,在达到多大内存后,进入 debug 模式
python -m memory_profiler --pdb-mmem=100 my_script.py
tracemalloc 是我最推荐的一个内存分析工具,它是 python 内置的跟踪 python 分配内存工具,主要提供:
- 回溯分配对象的位置
- 每个文件名和每个行号分配的内存块的统计信息:分配的内存块的总大小、数量和平均大小
- 计算两个快照之间的差异以检测内存泄漏
默认情况提供最近 1 帧的堆栈历史,可以通过PYTHONTRACEMALLOC选项来调整最大记录帧。 举个官方的示例,获取内存分配最多的 10 个文件 1.引入依赖,启动分析
import tracemalloc
tracemalloc.start()
# ... run your application ...
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
print("[ Top 10 ]")
for stat in top_stats[:10]:
print(stat)
2.查看结果
[ Top 10 ]
<frozen importlib._bootstrap>:716: size=4855 KiB, count=39328, average=126 B
<frozen importlib._bootstrap>:284: size=521 KiB, count=3199, average=167 B
/usr/lib/python3.4/collections/__init__.py:368: size=244 KiB, count=2315, average=108 B
/usr/lib/python3.4/unittest/case.py:381: size=185 KiB, count=779, average=243 B
/usr/lib/python3.4/unittest/case.py:402: size=154 KiB, count=378, average=416 B
/usr/lib/python3.4/abc.py:133: size=88.7 KiB, count=347, average=262 B
<frozen importlib._bootstrap>:1446: size=70.4 KiB, count=911, average=79 B
<frozen importlib._bootstrap>:1454: size=52.0 KiB, count=25, average=2131 B
<string>:5: size=49.7 KiB, count=148, average=344 B
/usr/lib/python3.4/sysconfig.py:411: size=48.0 KiB, count=1, average=48.0 KiB
除此之外,还可以对比两个快照之间的差别,这块比 memory_profiler 有更大的优势,能在运行时,动态的比较两次行为前后内存差异的原因。
0x02 常见场景
python 主要使用引用计数做 gc,例如对象被创建、引用,引用计数会加 1,当对象被销毁、离开作用域时会减 1,当某对象的引用计数值为 0 时,,那么它的内存就会被释放掉。 所以在某些场景下,python 不会主动去释放内存:
- 循环引用
循环引用的数据结构在 Python 中是一个很棘手的问题,因为正常的垃圾回收机制不能适用于这种情形。 例如考虑如下代码:
class Data:
def __del__(self):
print('Data.__del__')
# Node class involving a cycle
class Node:
def __init__(self):
self.data = Data()
self.parent = None
self.children = []
def add_child(self, child):
self.children.append(child)
child.parent = self
下面我们使用这个代码来做一些垃圾回收试验:
>>> a = Data()
>>> del a # Immediately deleted
Data.__del__
>>> a = Node()
>>> del a # Immediately deleted
Data.__del__
>>> a = Node()
>>> a.add_child(Node())
>>> del a # Not deleted (no message)
可以看到,最后一个的删除时打印语句没有出现。原因是 Python 的垃圾回收机制是基于简单的引用计数。 当一个对象的引用数变成 0 的时候才会立即删除掉。而对于循环引用这个条件永远不会成立。 因此,在上面例子中最后部分,父节点和孩子节点互相拥有对方的引用,导致每个对象的引用计数都不可能变成 0。
- 容器对象
例如,常见的 python 禁止参数默认值设置为 list 或 map,如下 key 一直不会被清空,并且占用越来越大
>>> def memory_leak(key=[]):
... key.append(1)
... print(len(key))
...
>>> memory_leak()
1
>>> memory_leak()
2
>>> memory_leak()
3
0xff 参考文档
- http://www.cppcns.com/jiaoben/python/365539.html
- https://segmentfault.com/a/1190000038277797
- https://docs.python.org/zh-cn/3/library/pdb.html
- https://docs.python.org/zh-cn/3/library/tracemalloc.html
- https://floatingoctothorpe.uk/2017/dumping-memory-with-gdb.html
- https://mozillazg.com/2017/07/debug-running-python-process-with-gdb.html
- https://man.cx/xxd(1)
- https://chase-seibert.github.io/blog/2013/08/03/diagnosing-memory-leaks-python.html
- https://python3-cookbook.readthedocs.io/zh_CN/latest/c08/p23_managing_memory_in_cyclic_data_structures.html