在最近的折腾中,发现某个 Python 程序运行,服务了一段时间以后老是占用很多很多的内存。

然后用了 这里 的方法去调查,发现是某个对象没有被释放,在内存中越积累越多。

当然,最后这问题得以解决了,在此将这个问题进行记录,也提醒自己以后不要犯这种问题。

来看其中这样的一组代码(将无关部分暂时隐去,想看完整的可以看这里):

cryptor.py:

这个的目的主要就是对于发送来的数据进行加解密,使用的时候将这个类实例化,或者直接调用最后的三个方法来加解密数据。

然后是这个,我们拿泄露的那个对象所在的 openssl 来说

可以看到,分为三个类,OpenSSLStreamCrypto 流加密类,OpenSSLAeadCrypto AEAD加密类,OpenSSLCryptoBase 基础加密类,前二者继承后者,在这上面重写方法来实现各自所需要的实现的特性。

总结来看,就和个链条一样, encrypt  里有个 Encrypt 类,这个类下调用 crypto 文件夹里各种加密库的类,将其包装好以便使用。

而就是前面这两个对象存在内存泄漏问题,没有被 Python 的垃圾回收机制给回收掉。

当然我们从这个项目的 commit 里也可以看到,之前的维护者做了很多努力,想解决这个问题,比如在抛出异常的时候调用 clean,将对象回收掉,但就下面的反馈来看似乎作用不太大,算是治标不治本。

Python 是判断对象还有没有在被调用来判断这个对象该不该回收的,所以就得从这个方面入手,看看有哪些该标记为没有被调用的对象还在被调用。

首先我想到的就是重写 encrypt 里的 _del_ 方法,这个是 Python 销毁对象的时候系统内部调用的方法。我就尝试重写了 _del_ ,在其中调用 crypto 里对应加密库类的 clean 方法。然后测试运行发现,内存泄露的速度确实减缓了一些,但也还是没有真正的解决问题。

再后来仔细观察,发现 主要就是 OpenSSL 流加密的对象泄露得最快,而 AEAD 的泄露又似乎少了一些。而对于其他的加密库,似乎并没有内存泄露。

再尝试用 objgraph 看了看(其实一开始就应该用这个看一看的),发现是 self.update 这个方法一直被调用着,导致对象无法被释放。

再仔细看看代码,发现为了使代码优雅一些,对于加密的调用分为四个方法 encrypt, decrypt, encrypt_once, decrypt_once 。Encrypt 类分别调用这四个方法,来实现不同的加密操作。

对于流加密来说,encrypt, decrypt, encrypt_once, decrypt_once 其实都是指向所调用的加密库类的 update 方法,在代码中是以下面的形式调用的(省略掉无关的代码了)。

而对于 AEAD 来说,则是下面这样

在 aead.py 的 AeadCryptoBase 类里:

存在这样的引用,组成了 AEAD 的加密方法的引用。

然后我就怀疑,是不是这种引用上存在一些坑,导致了对象在使用完毕后无法被正确地被系统识别和回收。

尝试将这些引用进行改写,比如

将其改写为

原先是 Encrypt 类调用 加密库类里“包装”好了的加密方法(encrypt, decrypt, encrypt_once, decrypt_once),加密库对外方法根据情况直接引用自身的方法引用方法,比如 在 流加密里 上面这四个函数都是指向自身的 update 方法,AEAD 里则是有两个是自身所拥有的方法,而另外两个则是指向自身的 update 方法。

而改写之后,就不是直接指向了,由改写之后的方法作为中转,在其中再调用各自应该调用的方法。

这样改写之后,经过长时间的测试,就再也没有发现内存泄露了,内存长时间的占用都维持在一个低位水平了。

原因分析,只能就我个人的猜测来说,我觉得是原先的引用方式,是直接引用的,调用到的直接是最终的函数了,这样调用确实是方便,减少了一些代码量。但这样每次调用之后,就需要对每个对象进行彻底的标记和释放,如果不标记,这样链式引用会造成系统无法知道从何处回收起,从而也就一直不被回收了。当然- -如果不嫌麻烦- -而且心够细的话,能在使用之后进行精确的标记,能让系统回收,那也行。

而改写之后,就不存在链式引用了,外部调用的就相当于调用被调用对象的自身方法了,从而系统能在这个对象使用完之后进行正确的回收。

对于自己来说,在以后的开发中也要自己注意一下,尽量不要进行类似的链式引用。