现象:

在压测的过程中,服务消耗的内存不断飙升,使用的内存大大超过了它可能消耗的内存大小

首先是内存泄漏的几个可能原因:

1、存在循环引用,gc不能释放;
2、存在全局对象,该对象不断的变大,占据内存;
3、使用了c或者c++扩展,扩展内存溢出了;

1、首先检查代码,把代码中可能发生内存小泄漏的地方全部修改下、代码中没有调用c或者c++的扩展库

 

2、查看下gc是否被禁止了

import gc
gc.isenabled()

得到的结果是True,说明gc是开启了的,可以手动执行下垃圾回收看是否会释放内存

gc.collect()

发现可用内存并没有增加,说明的确发生了内存泄漏,这部分内容无法被gc回收

 

3、使用objgraph查看下引用和对象的生成关系,看下内存消耗前10的对象变化情况

import objgraph
objgraph.show_most_common_types(limit=10)

可以得到如下结果:

function                   1246
wrapper_descriptor         1094
builtin_function_or_method 708
method_descriptor          540
dict                       496
weakref                    361
tuple                      243
list                       214
member_descriptor          192
getset_descriptor          171

压测一段时间后,top10的内存消耗并没有什么变化

 

4、使用objgraph.show_growth()、观察对应增长情况

objgraph.show_growth()

发现除了前面几次调用有增长外:

>>> objgraph.show_growth()
function                       1246     +1246
wrapper_descriptor             1081     +1081
builtin_function_or_method      708      +708
method_descriptor               540      +540
dict                            493      +493
weakref                         358      +358
tuple                           248      +248
list                            214      +214
member_descriptor               187      +187
getset_descriptor               166      +166
>>> objgraph.show_growth()
wrapper_descriptor     1094       +13
member_descriptor       192        +5
getset_descriptor       171        +5
weakref                 361        +3
dict                    496        +3

在压测一段时间后,上述内容也没有什么明显的变化了,但是此时内存的消耗却仍然在增加

 

5、是否是循环引用的问题?

如果怀疑某个对象出现了循环引用,可以通过objgraph工具来查看

比如下面这个例子中存在循环引用:

1. # -*- coding: utf-8 -*-
2. import objgraph, sys
3. class OBJ(object):
4.     pass
5.   
6. def show_direct_cycle_reference():
7.     a = OBJ()
8.     a.attr = a
9.     objgraph.show_backrefs(a, max_depth=5, filename = "direct.dot")
10.   
11. def show_indirect_cycle_reference():
12.     a, b = OBJ(), OBJ()
13.     a.attr_b = b
14.     b.attr_a = a
15.     objgraph.show_backrefs(a, max_depth=5, filename = "indirect.dot")
16.   
17. if __name__ == '__main__':
18.     if len(sys.argv) > 1:
19.         show_direct_cycle_reference()
20.     else:
21.         show_indirect_cycle_reference()

通过objgraph.show_backrefs来显示一个对象的引用情况, 上述代码会得到两个文件:

direct.dot、indirect.dot

可以用graphviz工具来打开.dot文件

a、首先下载graphviz工具、解压
b、解压后的目录中有个bin目录
c、打开dotty.exe文件
d、打开后右键,选择load graph,选择上述dot文件,就可以看到引用图,从而查看是否存在循环引用

针对上述代码得到的图如下:

Python内存溢出怎么排查 python内存溢出定位_Python内存溢出怎么排查

 

可以看到是存在循环引用的

再看下如下代码:

1. # -*- coding: utf-8 -*-
2. import objgraph, sys
3. class OBJ(object):
4.     pass
5.   
6. def direct_cycle_reference():
7.     a = OBJ()
8.     a.attr = a
9.      
10. if __name__ == '__main__':
11.     direct_cycle_reference()
12.     objgraph.show_backrefs(objgraph.by_type('OBJ')[0], max_depth=5, filename = "direct.dot"

得到的引用图如下:

Python内存溢出怎么排查 python内存溢出定位_压测_02

这个是没有循环引用的情况

这个方法我没有用好,没有得出啥有用的结论,出来,但是通过下面的这步,其实也可以判断出代码中并不存在内存泄漏

 

6、代码中是否存在内存泄漏的判断

其实从top命令的RES一栏中也可以看出来,虽然服务消耗的内存在增长,进程消耗的物理内存RES其实并没有增长,一直都稳定在2.45g没有变化,所以通过这个方式可排除是代码中出现了内存泄漏的情况, 应该是别的某个地方出现了内存泄漏

 

可以在Handler的post方法中添加一段循环引用的代码看下会出现什么,如下:

@tornado.gen.coroutine
    def post(self):
        a = [i for i in range(1000000)]
        b = [i for i in range(900000)]
        a.append(b)
        b.append(a)

现象:

进程消耗的物理内存RES在不断的上升,这种才是进程内部发生了内存泄漏
同时另外一个现象是,服务很卡顿,应该是在频繁的进行垃圾回收导致的

所以得出的一个结论是,代码中并没有内存泄漏,否则进程的RES值会一直上升的, 看来得从其他方便找问题, 我把post请求中我的代码全部注释掉,只留一句:

self.write('11')

发现问题依然存在,这也证明了,内存泄漏并不是代码导致的。

 

8、TIME_WAIT的问题

我直接把tornado服务中的核心api全部注释掉,就剩下一个空的tornado服务在跑,如上,内存仍然一直在减少。

此时因为服务端只有空服务,所以响应时间很短,在压测的时候,发现客户端和服务端都出现了大量的TIME_WAIT

 

TIME_WAIT是客户端才有的状态,为什么服务端会出现大量的TIME_WAIT? 

我现在以为是因为大量的连接处于TIME_WAIT状态导致连接没有及时关闭,所以内存消耗一直增加,于是我修改了几个内核参数来解决TIME_WAIT的问题

sysctl -w net.ipv4.tcp_tw_recycle=1 #及时回收
sysctl -w net.ipv4.tcp_tw_reuse=1   #tcp连接重用

客户端和服务端分别执行了这两个命令后, TIME_WAIT都没有了,但是此时压测的时候,发现服务端消耗的内存仍然在持续增加, 那看来不是TIME_WAIT的问题

 

9、难道问题出现在tornado内部?

于是我换了个tornado的版本试试,发现问题依然存在

目前的版本是5.1.1, 我尝试了最新的6.0.2, 问题依然存在

 

10、尝试pyrasite工具

这个工具主要是看内存中哪些对象占用内存最多,可以渗透进入正在运行的python程序,动态修改里面的数据和代码

 

安装:

pip install pyrasite pyrasite-gui
sudo setsebool -P deny_ptrace=off  #针对fedora
$pyrasite-memory-viewer  <pid>

会得到一个如下图的东西, 这个过程耗时有点长

Python内存溢出怎么排查 python内存溢出定位_压测_03

就可以看到哪个对象占用内存最多了

在压测前和压测后,分别执行了一次,发现得到的结果基本没有什么变化,说明进程中没有新建什么对象,进程本身没有发生内存泄漏

 

每个字段的含义如下:

Index : 行索引号
Count : 该类型的对象总数
%(Count) : 该类型的对象总数 占 所有类型的对象总数 的百分比
Size : 该类型的对象总字节数
%(Size) : 该类型的对象总字节数 占 所有类型的对象总字节数 的百分比
Cum : 累积行索引后的%(Size)
Max : 该类型的对象中,最大者的字节数
Kind : 类型

11、tracemalloc工具

可以直接看到哪个对象占了最大的空间,调用栈是啥样的,但是对于python2而言要安装的话需要重新编译python,python3内置。

比较麻烦,没有进行尝试

 

13、尝试gc.collect()

压测一段时间后,尝试手动执行gc

> gc.collect()   #0 
> gc.garbage  #[]

gc.collect()的结果为0,没有回收到有效对象, gc.garbage的结果为[],也没有无法回收的垃圾对象

 

14、到这里基本可以得出一个结论:

结论: 我的代码其实本身并没有内存泄漏

问题:但是为什么在压测的过程中机器的内存一直在减少?

原因:发生了Copy-on-Write

 

详细分析:

由于我的tornado服务是在主进程中fork了多个子进程,在主进程中加载了一个几百兆的dict后传入到了Handler对象中,因为这个dict是只读的,代码中不会对他修改,在服务启动的时候,子进程和父进程应该暂时还是共用的一个dict对象, 虽然用top看到的子进程也占用了内存,而且基本和父进程的内存一致,如下:

Python内存溢出怎么排查 python内存溢出定位_Python内存溢出怎么排查_04

其实此时,子进程是共用的父进程的内存

随着压测的进行,我在代码中通过如下方式实时获取了dict对象的引用计数,如下:

class SimilarityHandler(tornado.web.RequestHandler):
    def initialize(self, id_title, sentence_ids, id_sentences):
        self.id_title = id_title

    @tornado.gen.coroutine
    def post(self):
        print sys.getrefcount(self.id_title), 'id_title'

发现,该对象的引用计数一直都在变化,可能我们直观的认为,只要这个id_title字典不修改,子进程就不会从主进程中拷贝一份到自己的内存空间,但是python不是c,即使不修改这个dict,只要这个实例的引用计数发生变化,那么还是会发生copy,如上已经证明了这个dict的引用计数一直在变,所以子进程应该是在不断的拷贝父进程的内存到自己的内存空间,从而导致机器的内存不断消耗。 但是最终这个服务消耗的内存肯定不会超过这个值:

主进程消耗内存*(1+子进程数)

如果超过了,那肯定就是真的发生内存泄漏了。

 

为什么这个dict的引用计数一直在变化?

因为只要读取它,那么它的引用计数就会发生变化。

 

在压测的过程中,虽然机器的内存一直在减少,但是进程的RES值并没有发生变化,因为这个值已经是它能使用的全部内存了,在压测的过程中子进程复制的新内存已经包含在了这个值中。

 

参考地址: linux是写时复制,但是python因为有引用计数,这本质上是对底层数据结构的写入,这就导致了Copy-on-Write发发生