Skip to content

python内存泄漏排查指北

Posted on:2021年7月27日 at 16:18 (11 min read)

0x00 背景

内存泄漏是程序常见的错误行为之一,在 uwsgi 或者 gunicorn 里可以配置 max-requests 参数,来决定执行多少次请求后,重启 worker 来对抗内存泄漏问题,此外语言还有 gc 机制,在一般情况都不需要考虑内存泄漏问题。 但是不正确的程序写法,在某些场景下会导致内存飙升,内存使用率居高不下,甚至 OOM。因此内存泄漏问题需要引起我们的重视,在出现内存泄漏问题时,及时定位和解决。

那怎么确定一个程序是发生了内存泄漏,而不是正常的程序内存申请占用呢? 在有监控的场景下如 grafana、zabbix 时,可以参看对应机器的内存使用情况,在相对较长的时间内,是否存在内存持续增长的情况。如下图,内存在缓慢持续上涨,说明极有可能发生了内存泄漏问题。 image-20210906163309172

0x01 排查工具

确定了内存泄漏问题后,就需要进一步排查到底是什么问题引起的内存泄漏了。朴素的排查思路是,观察内存上升时,被调用的服务。例如某个接口被调用后,内存增加,在接口调用结束一段时间后内存仍未释放(需要结合 gc 来看),则该接口发生内存泄漏的概率极大。除了定性分析,python 还有不少定量分析的工具,来辅助我们定位内存泄漏问题,主要有以下三类:

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

结果如图 image-20210906163357277 此外 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 不会主动去释放内存:

  1. 循环引用

循环引用的数据结构在 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。

  1. 容器对象

例如,常见的 python 禁止参数默认值设置为 list 或 map,如下 key 一直不会被清空,并且占用越来越大

>>> def memory_leak(key=[]):
...     key.append(1)
...     print(len(key))
...
>>> memory_leak()
1
>>> memory_leak()
2
>>> memory_leak()
3

0xff 参考文档