现象:
在压测的过程中,服务消耗的内存不断飙升,使用的内存大大超过了它可能消耗的内存大小
首先是内存泄漏的几个可能原因:
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文件,就可以看到引用图,从而查看是否存在循环引用
针对上述代码得到的图如下:
可以看到是存在循环引用的
再看下如下代码:
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"
得到的引用图如下:
这个是没有循环引用的情况
这个方法我没有用好,没有得出啥有用的结论,出来,但是通过下面的这步,其实也可以判断出代码中并不存在内存泄漏
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>
会得到一个如下图的东西, 这个过程耗时有点长
就可以看到哪个对象占用内存最多了
在压测前和压测后,分别执行了一次,发现得到的结果基本没有什么变化,说明进程中没有新建什么对象,进程本身没有发生内存泄漏
每个字段的含义如下:
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看到的子进程也占用了内存,而且基本和父进程的内存一致,如下:
其实此时,子进程是共用的父进程的内存
随着压测的进行,我在代码中通过如下方式实时获取了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发发生