python urllib内存泄露问题分析及总结

Posted by Shi Hai's Blog on February 23, 2023

一、问题背景

在python2/3中调用urllib模块会有循环引用没被释放,触发代码如下所示:

import gc
import sys


if sys.version_info.major == 3:
    from urllib import request
    urlopen = request.urlopen
else:
    import urllib2
    urlopen = urllib2.urlopen


gc.collect()
# the unreachable object will be freed if this flag not set
gc.set_debug(gc.DEBUG_SAVEALL)
print("before call urlopen(), the unreachable object len: %s" % len(gc.garbage))

a = urlopen('http://www.google.com')
del a
# check memory on memory leaks
gc.collect()
print("after call urlopen(), the unreachable object len: %s" % len(gc.garbage))

在python2.7执行示例代码可以看到如下输出:

before call urlopen(), the unreachable object len: 0
after call urlopen(), the unreachable object len: 21

在python3.9执行示例代码可以看到如下输出:

before call urlopen(), the unreachable object len: 0
after call urlopen(), the unreachable object len: 0

从触发用例输出结果初步看到python2.7的urllib2模块有内存泄露问题。

二、urllib2模块代码分析

修改测试代码对引用泄露进行分析,修改后的测试代码如下所示:

import gc
import pprint
import sys


if sys.version_info.major == 3:
    from urllib import request
    urlopen = request.urlopen
else:
    import urllib2
    urlopen = urllib2.urlopen


gc.collect()
# DEBUG_SAVEALL标志:所有的unreachable objects都会被保存到garbage中,而不是被释放
gc.set_debug(gc.DEBUG_SAVEALL)
print("Before calling urlopen(), the unreachable object len: %s" % len(gc.garbage))

f = urlopen('http://www.baidu.com')
del f

# check memory on memory leaks
gc.collect()
print("After calling urlopen(), the unreachable objects:")
print(pprint.pprint(gc.garbage))

输出结果如下所示,从这里看可以发现有7个不可达对象被gc回收掉。

Before calling urlopen(), the unreachable object len: 0
After calling urlopen(), the unreachable objects:
[<httplib.HTTPResponse instance at 0x7fa5260efd20>,
 {'_method': 'GET',
  'chunk_left': 'UNKNOWN',
  'chunked': 0,
  'debuglevel': 0,
  'fp': None,
  'length': 29506,
  'msg': <httplib.HTTPMessage instance at 0x7fa5260ef3c0>,
  'reason': 'OK',
  'recv': <bound method HTTPResponse.read of <httplib.HTTPResponse instance at 0x7fa5260efd20>>,
...

gcdebug信息可以看出明显属于urllib模块的类是HTTPResponseHTTPMessage。此时我们可以将此测试代码最后一行打上断点,通过objgraph模块将不可达对象的拓扑图打印出来,执行语句: objgraph.show_refs(objgraph.at(0x7f7393427820), filename="20230225 0x7f7393427820.png", max_depth=5, too_many=30)

由于对象的属性可能非常复杂,所以我们需要调整objgraph.show_refs()函数中的max_depthtoo_many参数来不断扩展对象拓扑结构来找循环引用。这里不建议一开始就将max_depthtoo_many调的很大,可能会导致图片渲染时间较长,另外数据太多会看不清楚依赖细节。0x7f7393427820对象生成的完整图片如下所示(博客中无法放大,需要把图片才能缩放看到详细细节,补充了一个细节截图): 从这个全局图里面我们可以看见确实有一个循环引用: 在Python.2.7.18模块库中搜索urllib2httplib等相关模块可以发现,只有此截图处(在urllib2.py中的AbstractHTTPHandler类中)有recv属性的定义。 实际这行赋值定义操作移至上层基类HTTPResponse中定义就不会有循环引用问题。在HTTPResponse.read()函数后面并行的添加如下代码:

class HTTPResponse:
    def read(self, amt=None):
       #...
    recv = read

然后重新执行测试用例会发现循环引用消失了。

Before calling urlopen(), the unreachable object len: 0
After calling urlopen(), collecting the unreachable objects: 0

三、扩展总结

Python代码内存泄露只会有两种情况: 1、对象资源的引用计数未清零:a、打开文件、连接等资源忘记关闭,而且是多次循环执行;b、对象间出现循环引用,GC无法释放不可达对象;
2、GC被人为或者系统关闭;

引用计数未清零

这段代码文件资源被打开后未被关闭,而且批量进行了文件的打开和写操作,这会导致内存泄露。但这段代码无法用gc.collect()gc.garbage检查得出,因为这段代码虽然内存泄露了,但资源对象还是可以达到的,在相关函数结束后可以正常被gc回收。

def write_log():
    f = open('log.txt', 'w')
    f.write('something happened')

for _ in range(10):
    write_log()

GC被人为或者系统关闭

在python2.7.6版本里面,使用subprocess模块可能会导致GC被关闭 bpo-1336

可以先用tracemalloc模块看代码内存的分配统计信息,再用gc模块或者objgraph工具对对象引用进一步统计分析。

四、参考文献