[Python] Debug python 程式裡的 memory leak 有感
最近很努力的在專案的 python 程式裡,尋找可能的 memory leak 點…
其實 python 是有 garbage collection 機制的,理論上不會 leak,
但是有可能會有過比較久時間才放掉 object 的情形,
假設每次 object 都吃掉不少記憶體的話,光看 process 的記憶體看起來就像是 memory leak….
研究了好幾篇文章,也自己做了不少實驗,有一些心得,
其中一個就是「使用 gc.garbage 來查 memory leak 要很小心」~
以下面的 School 和 Student class 來當例子,可以看到 School 和 Student 互相有參考到對方,
因此建立出來的 School 和 Student 物件是會產生 cyclic reference 的~
class School(object): def __init__(self): self._students = [] def add_student(self, student): student.set_school(self) self._students.append(student) class Student(object): def __init__(self, name): self._name = name self._school = None def set_school(self, school): self._school = school
在下面的程式裡,會產生一個 School 和一個 Student 物件,
由於 add_school_student() 在函式的第一行執行完之後,
就再也沒有(外部的)人參考到那個 School 和 Student 物件了,
但因為 School 和 Student 物件還互相參考對方,因此 reference count 不會變成 0,
這種 cyclic reference 的狀況下,python 的 garbage collection 會如何處理呢?
def add_school_student(name): School().add_student(Student(name)) show_gc() def show_gc(): gc.set_debug(gc.DEBUG_UNCOLLECTABLE | gc.DEBUG_INSTANCES | gc.DEBUG_OBJECTS | gc.DEBUG_SAVEALL) gc.collect() print "\ngarbage len", len(gc.garbage) print "garbages:", gc.garbage del gc.garbage[:] add_school_student("Albert")
執行完後,看到下面的結果:
garbage len 5 garbages: [<__main__.School object at 0x106cacdd0>, [<__main__.Student object at 0x106cace10>], {'_students': [<__main__.Student object at 0x106cace10>]}, <__main__.Student object at 0x106cace10>, {'_school': <__main__.School object at 0x106cacdd0>, '_name': 'Albert'}]
我很自然地把上面的結果,解釋成:
「python 的 garbage collection 不會清除這種 cyclic reference,要自己把 cyclic reference 打斷才行」
但事實上並非如此… Python 的 garbage collection 是會自動偵測出這種 cyclic reference,
並且將之打斷後回收的,不過有幾個但書:
1. cyclic reference chain 裡面的物件不能有多個定義 __del__() 函式的,否則就沒辦法決定清除物件的順序了
這個敘述看起來不是很好懂… 假設我們在 School 和 Student 裡面各加上一個 __del__() 函式,
那麼,假設 garbage collection 想先清除 School 物件,
它會發現 Student 也參考到 School 物件,因此「待會」要清除 Student 物件時,
School 物件應該要活著 (因為 Student.__del__() 可能會用到 School 物件),
反之亦然,因此 python 在沒辦法決定究竟誰該先被清除的狀況下,
就會放棄 garbage collection,把整個 cyclic reference chain 都留到程式的最後~
2. 這種 cyclic reference 的偵測與打斷是需要時間等待的
當外部的參考都消失,只剩下 cyclic reference chain 內部的物件互相參考時,
因為 reference count 還沒降為 0,因此不會立刻被清除,
而要等到 garbage collection 的時間到,才會來檢查…
garbage collection 的時間不容易估計,以預設值來說,
要等到 新產生的物件數量 – 消滅掉的物件數量 >= 700 才會作 garbage collection…
那假設你產生了一個 cyclic reference,接著一直產生後就消滅物件的話,
如果增加的沒有比被消滅的多出 700 個,那 garbage collection 就一直不會被啟動,
cyclic reference chain 造成的 “memory leak” 也就會一直佔住記憶體了…
當然另一個作法是手動呼叫 gc.collect() 立刻啟動 garbage collection,
但是這就不是自動化的了,可能得有個 thread 定時作這件事情~
回到剛剛執行的結果吧~
這邊看到的 garbage 事實上是 garbage collection 可以清除掉的,
但因為我們為了 debug memory leak,加上了 set_debug(gc.DEBUG_SAVEALL) 的選項,
導致原本可以被 garbage collection 清除的 School/Student 物件都被存到 gc.garbage 裡,
雖然接下來我們就用 del gc.garbage[:] 將對他們的參考拿掉,
但是得等到下次的 garbage collection 才會再清除…
正確的 show_gc() 裡面,應該是先不用 gc.DEBUG_SAVEALL,
直接 gc.collect() 將垃圾都清乾淨,接著再用 gc.DEBUG_SAVEALL 檢查是否還有清不掉的垃圾,
這時就會發現 gc.garbage 不會有東西,因為都被清掉了:
def show_gc(): gc.set_debug(gc.DEBUG_UNCOLLECTABLE | gc.DEBUG_INSTANCES | gc.DEBUG_OBJECTS) gc.collect() gc.set_debug(gc.DEBUG_UNCOLLECTABLE | gc.DEBUG_INSTANCES | gc.DEBUG_OBJECTS | gc.DEBUG_SAVEALL) gc.collect() print "\ngarbage len", len(gc.garbage) print "garbages:", gc.garbage
修正完的程式再執行一次,garbage 都是空的囉:
garbage len 0 garbages: []
當然以節省資源的角度來看的話,最好是程式主動 break 掉 cyclic reference,
這樣子不需要呼叫 gc.collect() 也能讓物件立刻被回收,
不過定時呼叫 gc.collect() 算是一個比較不傷腦力的 workaround,
因為有時候你可能會發現要主動 break 掉 cyclic reference 沒有那麼容易,
尤其是在 multi-thread 程式裡,你還不確定會不會有人在 cyclic reference break 掉後,
又跑來用這個被參考到的物件…
如果想要找出 cyclic reference,然後自己在程式裡將它們打斷,
原本一開始「錯誤的」 show_gc() 函式寫法是 OK 的,
因為它可以把 python 認為是 garbage 的東西留下來給我們 debug~
當我們把 cyclic reference 打斷之後,它就不再會出現在 gc.garbage 裡面,
可以繼續尋找下一個 garbage 了~
舉 ast.py 模組提供的 literal_eval() 為例吧~
我是在 gc.garbage 裡面,發現有很多 cell 和 function 物件,
而這件 function 物件的名稱都是 _convert,看一下它們的 func_code 指向是 ast.py 模組…
嗯… 那就找 python 的原始碼來看看吧,
在 Lib/ast.py 可以找到下面這段包含著 _convert() 的 literal_eval() 函式:
def literal_eval(node_or_string): """ Safely evaluate an expression node or a string containing a Python expression. The string or node provided may only consist of the following Python literal structures: strings, numbers, tuples, lists, dicts, booleans, and None. """ _safe_names = {'None': None, 'True': True, 'False': False} if isinstance(node_or_string, basestring): node_or_string = parse(node_or_string, mode='eval') if isinstance(node_or_string, Expression): node_or_string = node_or_string.body def _convert(node): if isinstance(node, Str): return node.s elif isinstance(node, Num): return node.n elif isinstance(node, Tuple): return tuple(map(_convert, node.elts)) elif isinstance(node, List): return list(map(_convert, node.elts)) elif isinstance(node, Dict): return dict((_convert(k), _convert(v)) for k, v in zip(node.keys, node.values)) elif isinstance(node, Name): if node.id in _safe_names: return _safe_names[node.id] elif isinstance(node, BinOp) and \ isinstance(node.op, (Add, Sub)) and \ isinstance(node.right, Num) and \ isinstance(node.right.n, complex) and \ isinstance(node.left, Num) and \ isinstance(node.left.n, (int, long, float)): left = node.left.n right = node.right.n if isinstance(node.op, Add): return left + right else: return left - right raise ValueError('malformed string') return _convert(node_or_string)
參考一下網路上的文章,這種 nested recursive function 會形成 closure:
Reference cycles with closures
Garbage collection of recursive inner function
而 closure 在 python 資料結構裡就會產生 cell 物件,
用 gc.get_referrers() 去查這 cell 物件,就知道它會參考到 function,
而 function 又參考到 cell,導致了 cyclic reference…
因此做一下實驗的話,每呼叫一次 ast.literal_eval(“{‘a’: 1}”) 這樣簡單的敘述,
就會多一個 _convert 的 function 物件及好幾個 cell 物件…
如果將 nested resursive function 稍微改寫一下,讓 _convert 在用完之後就清除掉設成 None,
這個 cyclic reference 就斷掉了,因此就不再會出現在 gc.garbage 裡面了~
下面的函式就是將原本的 literal_eval() 在最後面將 _convert 設成 None:
def literal_eval(node_or_string): """ This function is copied from ast module. We modified this function to set inner function _convert to None at the end, so that the closure can be cleaned up instead of becoming a garbage that cannot be collected by gc. """ import ast _safe_names = {'None': None, 'True': True, 'False': False} if isinstance(node_or_string, basestring): node_or_string = ast.parse(node_or_string, mode='eval') if isinstance(node_or_string, ast.Expression): node_or_string = node_or_string.body def _convert(node): if isinstance(node, ast.Str): return node.s elif isinstance(node, ast.Num): return node.n elif isinstance(node, ast.Tuple): return tuple(map(_convert, node.elts)) elif isinstance(node, ast.List): return list(map(_convert, node.elts)) elif isinstance(node, ast.Dict): return dict((_convert(k), _convert(v)) for k, v in zip(node.keys, node.values)) elif isinstance(node, ast.Name): if node.id in _safe_names: return _safe_names[node.id] elif isinstance(node, ast.BinOp) and \ isinstance(node.op, (ast.Add, ast.Sub)) and \ isinstance(node.right, ast.Num) and \ isinstance(node.right.n, complex) and \ isinstance(node.left, ast.Num) and \ isinstance(node.left.n, (int, long, float)): left = node.left.n right = node.right.n if isinstance(node.op, ast.Add): return left + right else: return left - right raise ValueError('malformed string') result = _convert(node_or_string) # Break the cyclic reference from _convert -> closure data -> _convert _convert = None return result
像上述這類的 cyclic reference,假設會造成大量記憶量被佔住的話,
建議還是儘可能自己在程式中打斷,
其他小小的垃圾是可以留給 garbage collection 再來清除就好了,省點功夫~