Python應用程序內存泄漏的調試
Quake Lee
新浪網技術(中國)有限公司
Python-LDAP是什麼?
- Python-LDAP是一個第三方的開源項目,主要目標是實現python的LDAP接口, 這是一個由C語言編寫的Python擴展模塊。
- 該模塊的主要功能是把經過C接口調用libldap從中取出的數據,轉換成爲Python的對象, 除此以外還有逆向轉換
Python-LDAP存在的問題
- C接口調用程序內存泄漏
- C接口程序Python引用計數器泄漏
分析可能的泄漏點
- 測試本身編寫的程序時,發現程序嚴重消耗內存。在進行53萬次查詢後,程序佔用的內存增大了4倍
- 懷疑程序存在內存泄漏,通過測試,確認程序佔用內存的數量的確隨查詢量增加而增加。確認存在內存泄漏。
- 判斷存在三種可能性:
- Python解釋器存在內存泄漏
- 咱們本身開發的Python程序未能及時釋放必須手動釋放的資源
- TwistedCore存在內存泄漏
- 調用的某個Python擴展模塊出現內存泄漏
可能性一:Python解釋器內存泄漏
- 調試步驟:
- 首先檢查了Python的Bug Manager,查看Python 2.4.1 之後,報告的和解決掉的關於Python的內存管理問題
- Python近兩年來只有3個關於內存泄漏方面的問題被人報告出來,兩個是關於urllib2的。 另外一個是關於dict的未能確認的泄漏,但只是出如今對dict進行特殊的使用時。
- 據此判斷在個人程序的使用方式下Python解釋器出現內存泄漏的可能性很低。
可能性二:Python代碼未能釋放必須
手動釋放的資源
- 調試步驟:
- 手動釋放全部不須要持續使用的資源,再次測試
- 內存佔用沒有變化,依然保持原樣
- 說明不是Python代碼形成的內存泄漏
可能性三:TwistedCore存在內存泄漏
- 調試步驟:
- 查詢了Twisted的Tickets系統
- Twisted確實在近期出現過一些內存泄漏的狀況,而且包括一個Twisted 2.1.0未修復的
- 升級Twisted到Twisted 2.2.0
- 再次測試,內存佔用仍然沒有變化,說明:
- 可能TwistedCore不存在內存泄漏
- 還存在未能發現的TwistedCore內存泄漏
- Twisted代碼比較多,結構複雜,先跳過這種可能性
可能性四:某個Python擴展模塊出現內存泄漏
- 程序主要依賴的擴展模塊就是Python-LDAP
- 該模塊存在內存泄漏的可能比較高
- 如何進行調試呢?
內存泄漏調試過程
- 使用分析工具Valgrind對整個應用程序的運行過程進行分析
- 運行命令行
valgrind -v --leak-check=yes --num-callers=256 --logfile=d python d.py
- 發現openldap 2.3.19中libldap.so包含兩處內存泄漏
- ldap初始化的時候解析域名時存在的一次性泄漏
- 多線程併發狀況下,斷開鏈接時忘記釋放的mutex
仍然泄漏
- 對第二個泄漏進行了修復
- 再次測試,發現仍然泄漏
- 此時valgrind的報告中只剩下一個一次性的泄漏了,這個泄漏只有49個字節
- 內存都讓誰佔了呢?
分析結果
9676796 bytes in 235920 blocks are still reachable in loss record 32 of 32
at 0x3C032183: malloc (vg_replace_malloc.c:105)
by 0x80D9CA0: _PyObject_GC_Malloc (gcmodule.c:1248)
by 0x80D9D6B: _PyObject_GC_NewVar (gcmodule.c:1279)
by 0x80858D3: PyTuple_New (tupleobject.c:68)
by 0x808D9FF: PyType_Ready (typeobject.c:3167)
by 0x808D9C5: PyType_Ready (typeobject.c:3153)
by 0x807C8DB: _Py_ReadyTypes (object.c:1805)
by 0x80D1BC5: Py_InitializeEx (pythonrun.c:167)
by 0x80D1F8C: Py_Initialize (pythonrun.c:283)
by 0x80558C3: Py_Main (main.c:418)
by 0x805520C: main (python.c:23)
難道是Python解釋器泄漏了?
- 根據上述報告分析,大量的內存都由python解釋器的gcmodule控制
- 難道是gcmodule的回收機制出現了問題?
Python的Garbage Collector機制
- Python的gc是分代垃圾回收機制,共分三代
- 經過引用計數判斷數據單元是否能夠回收
- 經過擴展模塊gc中的接口能夠分析調試垃圾回收的狀況
- 具體功能請參見Python手冊
透過GC看內存
- Python的gc模塊功能仍是很強大的
- 使用get_objects( )方法能夠取得當前全部不能回收的對象(引用計數不爲0)的列表
- 存放在列表中的是全部對象的wrapper
吃內存的老鼠抓住了
- 一般狀況下,不能釋放的對象不會太多,啓動時在二、3萬個左右
- 我發現程序長時間運行後,不可釋放的對象總數大量增長。
- 最終確認,不可釋放的對象數量隨查詢量增大
- 寫個循環把他們都打印出來
- 發現了大量的空白列表對象,而且這些空白的列表不可以被GC回收,只有一種可能性, 它們的引用計數不爲0
這些列表那兒來的?
- 在Python代碼中建立的對象,使用完畢後引用計數器就會歸零
- 個人代碼中也已經手動刪除了全部能夠釋放掉的資源,尤爲是列表對象
- 嚴重懷疑,這些東西來自於Python-LDAP擴展模塊
關於Python擴展模塊
- Python的擴展模塊在操做Python對象以後,也須要改變對象的引用計數器
- 擴展模塊中的C程序一旦忘記修改或者不能正確的修改引用計數器,這些對象就成了殭屍對象
如何找到bug?
- python-ldap有2k多行C代碼,雖然說不是太多,但要是找起來也是大海撈針。
- 對Python擴展模塊的機制不是很熟悉,很難看出代碼的錯誤
- 給做者寫信?這確實不是咱們的風格,拿不出來patch,那兒好意思跟你們打招呼:)
- 開發任務時間緊,靠別人不如靠本身
- 不過問題還真是棘手,這種問題連valgrind也查不出來啦
靜下心來好好想
- 出問題的應該確定是Python-LDAP模塊了
- 個人程序調用它的次數並非不少,每次查詢調一次
- 如今已經知道泄漏的對象是空白的列表
- 因而...
添加檢查點
- 在每一個調用python-ldap的語句先後都添加檢查點
![Check Code](http://static.javashuo.com/static/loading.gif)
抓住bug了
針對每次調用添加檢查點後,馬上就找到了泄漏點。正是在ldapsearch完畢, 獲取ldap查詢結果以後,出現了一個空白的列表。通過對那段代碼進行分析, 仍然沒法確認是那行代碼寫錯了,由於對Python擴展模塊操做引用計數器的 方法不是很熟悉。當改用同步方式查詢ldap的時候,發現沒有泄漏出空白列 表。經過比較這兩種狀況下代碼的執行路徑,發如今異步查詢的狀況下,在 返回的ldap控制碼爲空的時候,python-ldap返回了一個空白的列表給python 解釋器,可是並無講這個空白列表的引用計數器減掉,致使這個列表的引 用計數一直不能清零,gc也不能回收這個對象。python
問題就出在這裏
![bug Code](http://static.javashuo.com/static/loading.gif)
災難尚未過去
- 再次進行測試,程序終於再也不內存泄漏了,可以健康的運行了
- 立刻上線運行!
- 程序再次長大……真是福無雙至,禍不單行
不要慌
- 測試的時候,明明沒有泄漏了,上線了又不行這是爲何?
- 測試的時候,只測試了查的到的條件。
- 上線後,多數狀況下,是查不到這個結果的
再次祭出法寶-添加檢查點
此次測試查不到的狀況,左測右測,再也沒有空白列表了…… 再次打出全部不能釋放的對象,發現不能釋放的對象並無增長 也沒有太多看起來可疑的殭屍對象。添加檢查點失敗……多線程
再靜心想一想
- 此次有什麼不一樣:
- 不能釋放的對象沒有顯著增長,甚至還比剛啓動的時候減小了。
- 打印全部的不能釋放掉的對象,沒有發現大量的相同對象
還記得Valgrind麼?
valgrind此次真的救了咱們。經過valgrind,咱們發現python-ldap的c代碼部分存在一個 嚴重的內存泄漏。當一個ldap記錄沒有查到的時候,一些struct在沒有釋放的狀況下程序 就直接返回了。併發