1. 程式人生 > >使用 GC、Objgraph 幹掉 Python 記憶體洩露與迴圈引用

使用 GC、Objgraph 幹掉 Python 記憶體洩露與迴圈引用

Python使用引用計數和垃圾回收來做記憶體管理,前面也寫過一遍文章《Python記憶體優化》,介紹了在python中,如何profile記憶體使用情況,並做出相應的優化。本文介紹兩個更致命的問題:記憶體洩露與迴圈引用。記憶體洩露是讓所有程式設計師都聞風喪膽的問題,輕則導致程式執行速度減慢,重則導致程式崩潰;而迴圈引用是使用了引用計數的資料結構、程式語言都需要解決的問題。本文揭曉這兩個問題在python語言中是如何存在的,然後試圖利用gc模組和objgraph來解決這兩個問題。

注意:本文的目標是Cpython,測試程式碼都是執行在Python2.7。另外,本文不考慮C擴充套件造成的記憶體洩露,這是另一個複雜且頭疼的問題。

一分鐘版本

  1. python使用引用計數和垃圾回收來釋放(free)Python物件
  2. 引用計數的優點是原理簡單、將消耗均攤到執行時;缺點是無法處理迴圈引用
  3. Python垃圾回收用於處理迴圈引用,但是無法處理迴圈引用中的物件定義了__del__的情況,而且每次回收會造成一定的卡頓
  4. gc module是python垃圾回收機制的介面模組,可以通過該module啟停垃圾回收、調整回收觸發的閾值、設定除錯選項
  5. 如果沒有禁用垃圾回收,那麼Python中的記憶體洩露有兩種情況:要麼是物件被生命週期更長的物件所引用,比如global作用域物件;要麼是迴圈引用中存在__del__
  6. 使用gc module、objgraph可以定位記憶體洩露,定位之後,解決很簡單
  7. 垃圾回收比較耗時,因此在對效能和記憶體比較敏感的場景也是無法接受的,如果能解除迴圈引用,就可以禁用垃圾回收。
  8. 使用gc module的DEBUG選項可以很方便的定位迴圈引用,解除迴圈引用的辦法要麼是手動解除,要麼是使用weakref

python記憶體管理

Python中,一切都是物件,又分為mutable和immutable物件。二者區分的標準在於是否可以原地修改,“原地“”可以理解為相同的地址。可以通過id()檢視一個物件的“地址”,如果通過變數修改物件的值,但id沒發生變化,那麼就是mutable,否則就是immutable。比如:

Python

 

1

2

3

4

5

6

7

8

9

>>> a = 5;id(a)

 

35170056

>>> a = 6;id(a)

35170044

>>> lst = [1,2,3]; id(lst)

39117168

>>> lst.append(4); id(lst)

39117168

 

a指向的物件(int型別)就是immutable, 賦值語句只是讓變數a指向了一個新的物件,因為id發生了變化。而lst指向的物件(list型別)為可變物件,通過方法(append)可以修改物件的值,同時保證id一致。

判斷兩個變數是否相等(值相同)使用==, 而判斷兩個變數是否指向同一個物件使用 is。比如下面a1 a2這兩個變數指向的都是空的列表,值相同,但是不是同一個物件。

Python

 

1

2

3

4

5

>>> a1, a2 = [], []

>>> a1 == a2

True

>>> a1 is a2

False

為了避免頻繁的申請、釋放記憶體,避免大量使用的小物件的構造析構,python有一套自己的記憶體管理機制。在鉅著《Python原始碼剖析》中有詳細介紹,在python原始碼obmalloc.h中也有詳細的描述。如下所示:

1089769-20170919090908056-1998847597

可以看到,python會有自己的記憶體緩衝池(layer2)以及物件緩衝池(layer3)。在Linux上執行過Python伺服器的程式都知道,python不會立即將釋放的記憶體歸還給作業系統,這就是記憶體緩衝池的原因。而對於可能被經常使用、而且是immutable的物件,比如較小的整數、長度較短的字串,python會快取在layer3,避免頻繁建立和銷燬。例如:

Python

 

1

2

3

4

5

6

7

8

9

>>> a, b = 1, 1

>>> a is b

True

>>> a, b = (), ()

>>> a is b

True

>>> a, b = {}, {}

>>> a is b

False

本文並不關心python是如何管理記憶體塊、如何管理小物件,感興趣的讀者可以參考伯樂線上csdn上的這兩篇文章。

本文關心的是,一個普通的物件的生命週期,更明確的說,物件是什麼時候被釋放的。當一個物件理論上(或者邏輯上)不再被使用了,但事實上沒有被釋放,那麼就存在記憶體洩露;當一個物件事實上已經不可達(unreachable),即不能通過任何變數找到這個物件,但這個物件沒有立即被釋放,那麼則可能存在迴圈引用。

引用計數

引用計數(References count),指的是每個Python物件都有一個計數器,記錄著當前有多少個變數指向這個物件。

將一個物件直接或者間接賦值給一個變數時,物件的計數器會加1;當變數被del刪除,或者離開變數所在作用域時,物件的引用計數器會減1。當計數器歸零的時候,代表這個物件再也沒有地方可能使用了,因此可以將物件安全的銷燬。Python原始碼中,通過Py_INCREF和Py_DECREF兩個巨集來管理物件的引用計數,程式碼在object.h

 

1

2

3

4

5

6

7

8

9

10

11

12

#define Py_INCREF(op) (                         \

    _Py_INC_REFTOTAL  _Py_REF_DEBUG_COMMA       \

    ((PyObject*)(op))->ob_refcnt++)

 

#define Py_DECREF(op)                                   \

    do {                                                \

        if (_Py_DEC_REFTOTAL  _Py_REF_DEBUG_COMMA       \

        --((PyObject*)(op))->ob_refcnt != 0)            \

            _Py_CHECK_REFCNT(op)                        \

        else                                            \

        _Py_Dealloc((PyObject *)(op));                  \

    } while (0)

通過sys.getrefcount(obj)物件可以獲得一個物件的引用數目,返回值是真實引用數目加1(加1的原因是obj被當做引數傳入了getrefcount函式),例如:

Python

 

1

2

3

4

5

6

7

>>> import sys

>>> s = 'asdf'

>>> sys.getrefcount(s)

2

>>> a = 1

>>> sys.getrefcount(a)

605

從物件1的引用計數資訊也可以看到,python的物件緩衝池會快取十分常用的immutable物件,比如這裡的整數1。

引用計數的優點在於原理通俗易懂;且將物件的回收分佈在程式碼執行時:一旦物件不再被引用,就會被釋放掉(be freed),不會造成卡頓。但也有缺點:額外的欄位(ob_refcnt);頻繁的加減ob_refcnt,而且可能造成連鎖反應。但這些缺點跟迴圈引用比起來都不算事兒。

什麼是迴圈引用,就是一個物件直接或者間接引用自己本身,引用鍊形成一個環。且看下面的例子:

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

# -*- coding: utf-8 -*-

import objgraph, sys

class OBJ(object):

    pass

 

def show_direct_cycle_reference():

    a = OBJ()

    a.attr = a

    objgraph.show_backrefs(a, max_depth=5, filename = "direct.dot")

 

def show_indirect_cycle_reference():

    a, b = OBJ(), OBJ()

    a.attr_b = b

    b.attr_a = a

    objgraph.show_backrefs(a, max_depth=5, filename = "indirect.dot")

 

if __name__ == '__main__':

    if len(sys.argv) > 1:

        show_direct_cycle_reference()

    else:

        show_indirect_cycle_reference()

執行上面的程式碼,使用graphviz工具集(本文使用的是dotty)開啟生成的兩個檔案,direct.dot 和 indirect.dot,得到下面兩個圖

1089769-20170919090908056-19988475971089769-20170919090908056-1998847597

通過屬性名(attr, attr_a, attr_b)可以很清晰的看出迴圈引用是怎麼產生的

前面已經提到,對於一個物件,當沒有任何變數指向自己時,引用計數降到0,就會被釋放掉。我們以上面左邊那個圖為例,可以看到,紅框裡面的OBJ物件想在有兩個引用(兩個入度),分別來自幀物件frame(程式碼中,函式區域性空間持有對OBJ例項的引用)、attr變數。我們再改一下程式碼,在函式執行技術之後看看是否還有OBJ類的例項存在,引用關係是怎麼樣的:

 

1

2

3

4

5

6

7

8

9

10

11

12

# -*- coding: utf-8 -*-

import objgraph, sys

class OBJ(object):

    pass

 

def direct_cycle_reference():

    a = OBJ()

    a.attr = a

    

if __name__ == '__main__':

    direct_cycle_reference()

    objgraph.show_backrefs(objgraph.by_type('OBJ')[0], max_depth=5, filename = "direct.dot"

1089769-20170919090908056-1998847597

修改後的程式碼,OBJ例項(a)存在於函式的local作用域。因此,當函式呼叫結束之後,來自幀物件frame的引用被解除。從圖中可以看到,當前物件的計數器(入度)為1,按照引用計數的原理,是不應該被釋放的,但這個物件在函式呼叫結束之後就是事實上的垃圾,這個時候就需要另外的機制來處理這種情況了。

python的世界,很容易就會出現迴圈引用,比如標準庫Collections中OrderedDict的實現(已去掉無關注釋):

 

1

2

3

4

5

6

7

8

9

10

11

class OrderedDict(dict):

    def __init__(self, *args, **kwds):

        if len(args) > 1:

            raise TypeError('expected at most 1 arguments, got %d' % len(args))

        try:

            self.__root

        except AttributeError:

            self.__root = root = []                     # sentinel node

            root[:] = [root, root, None]

            self.__map = {}

        self.__update(*args, **kwds)

注意第8、9行,root是一個列表,列表裡面的元素之自己本身!

垃圾回收

這裡強調一下,本文中的的垃圾回收是狹義的垃圾回收,是指當出現迴圈引用,引用計數無計可施的時候採取的垃圾清理演算法。

在python中,使用標記-清除演算法(mark-sweep)和分代(generational)演算法來垃圾回收。在《Garbage Collection for Python》一文中有對標記回收演算法,然後在《Python記憶體管理機制及優化簡析》一文中,有對前文的翻譯,並且有分代回收的介紹。在這裡,引用後面一篇文章:

在Python中, 所有能夠引用其他物件的物件都被稱為容器(container). 因此只有容器之間才可能形成迴圈引用. Python的垃圾回收機制利用了這個特點來尋找需要被釋放的物件. 為了記錄下所有的容器物件, Python將每一個 容器都鏈到了一個雙向連結串列中, 之所以使用雙向連結串列是為了方便快速的在容器集合中插入和刪除物件. 有了這個 維護了所有容器物件的雙向連結串列以後, Python在垃圾回收時使用如下步驟來尋找需要釋放的物件:

  1. 對於每一個容器物件, 設定一個gc_refs值, 並將其初始化為該物件的引用計數值.
  2. 對於每一個容器物件, 找到所有其引用的物件, 將被引用物件的gc_refs值減1.
  3. 執行完步驟2以後所有gc_refs值還大於0的物件都被非容器物件引用著, 至少存在一個非迴圈引用. 因此 不能釋放這些物件, 將他們放入另一個集合.
  4. 在步驟3中不能被釋放的物件, 如果他們引用著某個物件, 被引用的物件也是不能被釋放的, 因此將這些 物件也放入另一個集合中.
  5. 此時還剩下的物件都是無法到達的物件. 現在可以釋放這些物件了.

關於分代回收:

除此之外, Python還將所有物件根據’生存時間’分為3代, 從0到2. 所有新建立的物件都分配為第0代. 當這些物件 經過一次垃圾回收仍然存在則會被放入第1代中. 如果第1代中的物件在一次垃圾回收之後仍然存貨則被放入第2代. 對於不同代的物件Python的回收的頻率也不一樣. 可以通過gc.set_threshold(threshold0[, threshold1[, threshold2]]) 來定義. 當Python的垃圾回收器中新增的物件數量減去刪除的物件數量大於threshold0時, Python會對第0代物件 執行一次垃圾回收. 每當第0代被檢查的次數超過了threshold1時, 第1代物件就會被執行一次垃圾回收. 同理每當 第1代被檢查的次數超過了threshold2時, 第2代物件也會被執行一次垃圾回收.

注意,threshold0,threshold1,threshold2的意義並不相同

為什麼要分代呢,這個演算法的根源來自於weak generational hypothesis。這個假說由兩個觀點構成:首先是年親的物件通常死得也快,比如大量的物件都存在於local作用域;而老物件則很有可能存活更長的時間,比如全域性物件,module, class。

垃圾回收的原理就如上面提示,詳細的可以看Python原始碼,只不過事實上垃圾回收器還要考慮__del__,弱引用等情況,會略微複雜一些。

什麼時候會觸發垃圾回收呢,有三種情況:

  1. 達到了垃圾回收的閾值,Python虛擬機器自動執行
  2. 手動呼叫gc.collect()
  3. Python虛擬機器退出的時候

對於垃圾回收,有兩個非常重要的術語,那就是reachable與collectable(當然還有與之對應的unreachable與uncollectable),後文也會大量提及。

reachable是針對python物件而言,如果從根集(root)能到找到物件,那麼這個物件就是reachable,與之相反就是unreachable,事實上就是隻存在於迴圈引用中的物件,Python的垃圾回收就是針對unreachable物件。

而collectable是針對unreachable物件而言,如果這種物件能被回收,那麼是collectable;如果不能被回收,即迴圈引用中的物件定義了__del__, 那麼就是uncollectable。Python垃圾回收對uncollectable物件無能為力,會造成事實上的記憶體洩露。

gc module

這裡的gc(garbage collector)是Python 標準庫,該module提供了與上一節“垃圾回收”內容相對應的介面。通過這個module,可以開關gc、調整垃圾回收的頻率、輸出除錯資訊。gc模組是很多其他模組(比如objgraph)封裝的基礎,在這裡先介紹gc的核心API。

gc.enable(); gc.disable(); gc.isenabled()

開啟gc(預設情況下是開啟的);關閉gc;判斷gc是否開啟

gc.collection() 

執行一次垃圾回收,不管gc是否處於開啟狀態都能使用

gc.set_threshold(t0, t1, t2); gc.get_threshold()

設定垃圾回收閾值; 獲得當前的垃圾回收閾值

注意:gc.set_threshold(0)也有禁用gc的效果

gc.get_objects()

返回所有被垃圾回收器(collector)管理的物件。這個函式非常基礎!只要python直譯器執行起來,就有大量的物件被collector管理,因此,該函式的呼叫比較耗時!

比如,命令列啟動python

Python

 

1

2

3

>>> import gc

>>> len(gc.get_objects())

3749

gc.get_referents(*obj)

返回obj物件直接指向的物件

gc.get_referrers(*obj)

返回所有直接指向obj的物件

下面的例項展示了get_referents與get_referrers兩個函式

Python

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

>>> class OBJ(object):

 

... pass

...

>>> a, b = OBJ(), OBJ()

>>> hex(id(a)), hex(id(b))

('0x250e730', '0x250e7f0')

 

 

>>> gc.get_referents(a)

[<class '__main__.OBJ'>]

>>> a.attr = b

>>> gc.get_referents(a)

[{'attr': <__main__.OBJ object at 0x0250E7F0>}, <class '__main__.OBJ'>]

>>> gc.get_referrers(b)

[{'attr': <__main__.OBJ object at 0x0250E7F0>}, {'a': <__main__.OBJ object at 0x0250E730>, 'b': <__main__.OBJ object at 0x0250E7F0>, 'OBJ': <class '__main__.OBJ'>, '__builtins__': <modu

le '__builtin__' (built-in)>, '__package__': None, 'gc': <module 'gc' (built-in)>, '__name__': '__main__', '__doc__': None}]

>>>

a, b都是類OBJ的例項,執行”a.attr = b”之後,a就通過‘’attr“這個屬性指向了b。

gc.set_debug(flags)

設定除錯選項,非常有用,常用的flag組合包含以下

gc.DEBUG_COLLETABLE: 列印可以被垃圾回收器回收的物件

gc.DEBUG_UNCOLLETABLE: 列印無法被垃圾回收器回收的物件,即定義了__del__的物件

gc.DEBUG_SAVEALL:當設定了這個選項,可以被拉起回收的物件不會被真正銷燬(free),而是放到gc.garbage這個列表裡面,利於在線上查詢問題

記憶體洩露

既然Python中通過引用計數和垃圾回收來管理記憶體,那麼什麼情況下還會產生記憶體洩露呢?有兩種情況:

第一是物件被另一個生命週期特別長的物件所引用,比如網路伺服器,可能存在一個全域性的單例ConnectionManager,管理所有的連線Connection,如果當Connection理論上不再被使用的時候,沒有從ConnectionManager中刪除,那麼就造成了記憶體洩露。

第二是迴圈引用中的物件定義了__del__函式,這個在《程式設計師必知的Python陷阱與缺陷列表》一文中有詳細介紹,簡而言之,如果定義了__del__函式,那麼在迴圈引用中Python直譯器無法判斷析構物件的順序,因此就不錯處理。

在任何環境,不管是伺服器,客戶端,記憶體洩露都是非常嚴重的事情。

如果是線上伺服器,那麼一定得有監控,如果發現記憶體使用率超過設定的閾值則立即報警,儘早發現些許還有救。當然,誰也不希望在線上修復記憶體洩露,這無疑是給行駛的汽車換輪子,因此儘量在開發環境或者壓力測試環境發現並解決潛在的記憶體洩露。在這裡,發現問題最為關鍵,只要發現了問題,解決問題就非常容易了,因為按照前面的說法,出現記憶體洩露只有兩種情況,在第一種情況下,只要在適當的時機解除引用就可以了;在第二種情況下,要麼不再使用__del__函式,換一種實現方式,要麼解決迴圈引用。

那麼怎麼查詢哪裡存在記憶體洩露呢?武器就是兩個庫:gc、objgraph

在上面已經介紹了gc這個模組,理論上,通過gc模組能夠拿到所有的被garbage collector管理的物件,也能知道物件之間的引用和被引用關係,就可以畫出物件之間完整的引用關係圖。但事實上還是比較複雜的,因為在這個過程中一不小心又會引入新的引用關係,所以,有好的輪子就直接用吧,那就是objgraph

objgraph

objgraph的實現呼叫了gc的這幾個函式:gc.get_objects(), gc.get_referents(), gc.get_referers(),然後構造出物件之間的引用關係。objgraph的程式碼和文件都寫得比較好,建議一讀。

下面先介紹幾個十分實用的API

def count(typename)

返回該型別物件的數目,其實就是通過gc.get_objects()拿到所用的物件,然後統計指定型別的數目。

def by_type(typename)

返回該型別的物件列表。線上專案,可以用這個函式很方便找到一個單例物件

def show_most_common_types(limits = 10)

列印例項最多的前N(limits)個物件,這個函式非常有用。在《Python記憶體優化》一文中也提到,該函式能發現可以用slots進行記憶體優化的物件

def show_growth()

統計自上次呼叫以來增加得最多的物件,這個函式非常有利於發現潛在的記憶體洩露。函式內部呼叫了gc.collect(),因此即使有迴圈引用也不會對判斷造成影響。

值得一提,該函式的實現非常有意思,簡化後的程式碼如下:

 

1

2

3

4

5

6

7

8

9

10

11

def show_growth(limit=10, peak_stats={}, shortnames=True, file=None):

    gc.collect()

    stats = typestats(shortnames=shortnames)

    deltas = {}

    for name, count in iteritems(stats):

        old_count = peak_stats.get(name, 0)

        if count > old_count:

            deltas[name] = count - old_count

            peak_stats[name] = count

    deltas = sorted(deltas.items(), key=operator.itemgetter(1),

                    reverse=True)

注意形參peak_stats使用了可變引數作為預設形參,這樣很方便記錄上一次的執行結果。在《程式設計師必知的Python陷阱與缺陷列表》中提到,使用可變物件做預設形參是最為常見的python陷阱,但在這裡,卻成為了方便的利器!

def show_backrefs()

生產一張有關objs的引用圖,看出看出物件為什麼不釋放,後面會利用這個API來查記憶體洩露。

該API有很多有用的引數,比如層數限制(max_depth)、寬度限制(too_many)、輸出格式控制(filename output)、節點過濾(filter, extra_ignore),建議使用之間看一些document。

def find_backref_chain(obj, predicate, max_depth=20, extra_ignore=()):

找到一條指向obj物件的最短路徑,且路徑的頭部節點需要滿足predicate函式 (返回值為True)

可以快捷、清晰指出 物件的被引用的情況,後面會展示這個函式的威力

def show_chain():

將find_backref_chain 找到的路徑畫出來, 該函式事實上呼叫show_backrefs,只是排除了所有不在路徑中的節點。

查詢記憶體洩露

在這一節,介紹如何利用objgraph來查詢記憶體是怎麼洩露的

如果我們懷疑一段程式碼、一個模組可能會導致記憶體洩露,那麼首先呼叫一次obj.show_growth(),然後呼叫相應的函式,最後再次呼叫obj.show_growth(),看看是否有增加的物件。比如下面這個簡單的例子:

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

# -*- coding: utf-8 -*-

import objgraph

 

_cache = []

 

class OBJ(object):

    pass

 

def func_to_leak():

    o  = OBJ()

    _cache.append(o)

    # do something with o, then remove it from _cache

 

    if True: # this seem ugly, but it always exists

        return

    _cache.remove(o)

 

if __name__ == '__main__':

    objgraph.show_growth()

    try:

        func_to_leak()

    except:

        pass

    print 'after call func_to_leak'

    objgraph.show_growth()

執行結果(我們只關心後一次show_growth的結果)如下

Python

 

1

2

3

4

5

6

wrapper_descriptor 1073 +13

member_descriptor 204 +5

getset_descriptor 168 +5

weakref 338 +3

dict 458 +3

OBJ 1 +1

程式碼很簡單,函式開始的時候講物件加入了global作用域的_cache列表,然後期望是在函式退出之前從_cache刪除,但是由於提前返回或者異常,並沒有執行到最後的remove語句。從執行結果可以發現,呼叫函式之後,增加了一個類OBJ的例項,然而理論上函式呼叫結束之後,所有在函式作用域(local)中宣告的物件都改被銷燬,因此這裡就存在記憶體洩露。

當然,在實際的專案中,我們也不清楚洩露是在哪段程式碼、哪個模組中發生的,而且往往是發生了記憶體洩露之後再去排查,這個時候使用obj.show_most_common_types就比較合適了,如果一個自定義的類的例項數目特別多,那麼就可能存在記憶體洩露。如果在壓力測試環境,停止壓測,呼叫gc.collet,然後再用obj.show_most_common_types檢視,如果物件的數目沒有相應的減少,那麼肯定就是存在洩露。

當我們定位了哪個物件發生了記憶體洩露,那麼接下來就是分析怎麼洩露的,引用鏈是怎麼樣的,這個時候就該show_backrefs出馬了,還是以之前的程式碼為例,稍加修改:

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

import objgraph

 

_cache = []

 

class OBJ(object):

    pass

 

def func_to_leak():

    o  = OBJ()

    _cache.append(o)

    # do something with o, then remove it from _cache

 

    if True: # this seem ugly, but it always exists

        return

    _cache.remove(o)

 

if __name__ == '__main__':

    try:

        func_to_leak()

    except:

        pass

    objgraph.show_backrefs(objgraph.by_type('OBJ')[0], max_depth = 10, filename = 'obj.dot')

show_backrefs檢視記憶體洩露

注意,上面的程式碼中,max_depth引數非常關鍵,如果這個引數太小,那麼看不到完整的引用鏈,如果這個引數太大,執行的時候又非常耗時間。

然後開啟dot檔案,結果如下

1089769-20170919090908056-1998847597

可以看到洩露的物件(紅框表示),是被一個叫_cache的list所引用,而_cache又是被__main__這個module所引用。

對於示例程式碼,dot檔案的結果已經非常清晰,但是對於真實專案,引用鏈中的節點可能成百上千,看起來非常頭大,下面用tornado起一個最最簡單的web伺服器(程式碼不知道來自哪裡,且沒有記憶體洩露,這裡只是為了顯示引用關係),然後繪製socket的引用關關係圖,程式碼和引用關係圖如下:

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

import objgraph

import errno

import functools

import tornado.ioloop

import socket

 

def connection_ready(sock, fd, events):

    while True:

        try:

            connection, address = sock.accept()

            print 'connection_ready', address

        except socket.error as e:

            if e.args[0] not in (errno.EWOULDBLOCK, errno.EAGAIN):

                raise

            return

        connection.setblocking(0)

        # do sth with connection

 

 

if __name__ == '__main__':

    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)

    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

    sock.setblocking(0)

    sock.bind(("", 8888))

    sock.listen(128)

 

    io_loop = tornado.ioloop.IOLoop.current()

    callback = functools.partial(connection_ready, sock)

    io_loop.add_handler(sock.fileno(), callback, io_loop.READ)

    #objgraph.show_backrefs(sock, max_depth = 10, filename = 'tornado.dot')

    # objgraph.show_chain(

    #     objgraph.find_backref_chain(

    #         sock,

    #         objgraph.is_proper_module

    #     ),

    #     filename='obj_chain.dot'

    # )

    io_loop.start()

 

tornado_server例項

1089769-20170919090908056-1998847597

可見,程式碼越複雜,相互之間的引用關係越多,show_backrefs越難以看懂。這個時候就使用show_chain和find_backref_chain吧,這種方法,在官方文件也是推薦的,我們稍微改改程式碼,結果如下:

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

import objgraph

 

_cache = []

 

class OBJ(object):

    pass

 

def func_to_leak():

    o  = OBJ()

    _cache.append(o)

    # do something with o, then remove it from _cache

 

    if True: # this seem ugly, but it always exists

        return

    _cache.remove(o)

 

if __name__ == '__main__':

    try:

        func_to_leak()

    except:

        pass

    # objgraph.show_backrefs(objgraph.by_type('OBJ')[0], max_depth = 10, filename = 'obj.dot')

    objgraph.show_chain(

        objgraph.find_backref_chain(

            objgraph.by_type('OBJ')[0],

            objgraph.is_proper_module

        ),

        filename='obj_chain.dot'

    )

1089769-20170919090908056-1998847597

上面介紹了記憶體洩露的第一種情況,物件被“非期望”地引用著。下面看看第二種情況,迴圈引用中的__del__, 看下面的程式碼:

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

# -*- coding: utf-8 -*-

import objgraph, gc

class OBJ(object):

    def __del__(self):

        print('Dangerous!')

 

def show_leak_by_del():

    a, b = OBJ(), OBJ()

    a.attr_b = b

    b.attr_a = a

 

    del a, b

    print gc.collect()

 

    objgraph.show_backrefs(objgraph.by_type('OBJ')[0], max_depth = 10, filename = 'del_obj.dot')

上面的程式碼存在迴圈引用,而且OBJ類定義了__del__函式。如果沒有定義__del__函式,那麼上述的程式碼會報錯, 因為gc.collect會將迴圈引用刪除,objgraph.by_type(‘OBJ’)返回空列表。而因為定義了__del__函式,gc.collect也無能為力,結果如下:

1089769-20170919090908056-1998847597

從圖中可以看到,對於這種情況,還是比較好辨識的,因為objgraph將__del__函式用特殊顏色標誌出來,一眼就看見了。另外,可以看見gc.garbage(型別是list)也引用了這兩個物件,原因在document中有描述,當執行垃圾回收的時候,會將定義了__del__函式的類例項(被稱為uncollectable object)放到gc.garbage列表,因此,也可以直接通過檢視gc.garbage來找出定義了__del__的迴圈引用。在這裡,通過增加extra_ignore來排除gc.garbage的影響:

將上述程式碼的最後一行改成:

Python

 

1

  objgraph.show_backrefs(objgraph.by_type('OBJ')[0], extra_ignore=(id(gc.garbage),),  max_depth = 10, filename = 'del_obj.dot')

1089769-20170919090908056-1998847597

另外,也可以設定DEBUG_UNCOLLECTABLE 選項,直接將uncollectable物件輸出到標準輸出,而不是放到gc.garbage

迴圈引用

除非定義了__del__方法,那麼迴圈引用也不是什麼萬惡不赦的東西,因為垃圾回收器可以處理迴圈引用,而且不準是python標準庫還是大量使用的第三方庫,都可能存在迴圈引用。如果存在迴圈引用,那麼Python的gc就必須開啟(gc.isenabled()返回True),否則就會記憶體洩露。但是在某些情況下,我們還是不希望有gc,比如對記憶體和效能比較敏感的應用場景,在這篇文章中,提到instagram通過禁用gc,效能提升了10%;另外,在一些應用場景,垃圾回收帶來的卡頓也是不能接受的,比如RPG遊戲。從前面對垃圾回收的描述可以看到,執行一次垃圾回收是很耗費時間的,因為需要遍歷所有被collector管理的物件(即使很多物件不屬於垃圾)。因此,要想禁用GC,就得先徹底幹掉迴圈引用。

同記憶體洩露一樣,解除迴圈引用的前提是定位哪裡出現了迴圈引用。而且,如果需要在線上應用關閉gc,那麼需要自動、持久化的進行檢測。下面介紹如何定位迴圈引用,以及如何解決迴圈引用。

定位迴圈引用

這裡還是是用GC模組和objgraph來定位迴圈引用。需要注意的事,一定要先禁用gc(呼叫gc.disable()), 防止誤差。

這裡利用之前介紹迴圈引用時使用過的例子: a, b兩個OBJ物件形成迴圈引用

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

# -*- coding: utf-8 -*-

import objgraph, gc

class OBJ(object):

    pass

 

def show_cycle_reference():

    a, b = OBJ(), OBJ()

    a.attr_b = b

    b.attr_a = a

 

if __name__ == '__main__':

    gc.disable()

    for _ in xrange(50):

        show_cycle_reference()

    objgraph.show_most_common_types(20)

執行結果(部分):

Python

 

1

2

3

wrapper_descriptor 1060

dict 555

OBJ 100

上面的程式碼中使用的是show_most_common_types,而沒有使用show_growth(因為growth會手動呼叫gc.collect()),通過結果可以看到,記憶體中現在有100個OBJ物件,符合預期。當然這些OBJ物件沒有在函式呼叫後被銷燬,不一定是迴圈引用的問題,也可能是記憶體洩露,比如前面OBJ物件被global作用域中的_cache引用的情況。怎麼排除是否是被global作用域的變數引用的情況呢,方法還是objgraph.find_backref_chain(obj),在__doc__中指出,如果找不到符合條件的應用鏈(chain),那麼返回[obj],稍微修改上面的程式碼:

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

# -*- coding: utf-8 -*-

import objgraph, gc

class OBJ(object):

    pass

 

def show_cycle_reference():

    a, b = OBJ(), OBJ()

    a.attr_b = b

    b.attr_a = a

 

if __name__ == '__main__':

    gc.disable()

    for _ in xrange(50):

        show_cycle_reference()

    ret = objgraph.find_backref_chain(objgraph.by_type('OBJ')[0], objgraph.is_proper_module)

    print ret

上面的程式碼輸出:

Python

 

1

[<__main__.OBJ object at 0x0244F810>]

驗證了我們的想法,OBJ物件不是被global作用域的變數所引用。

在實際專案中,不大可能到處用objgraph.show_most_common_types或者objgraph.by_type來排查迴圈引用,效率太低。有沒有更好的辦法呢,有的,那就是使用gc模組的debug 選項。在前面介紹gc模組的時候,就介紹了gc.DEBUG_COLLECTABLE 選項,我們來試試:

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

# -*- coding: utf-8 -*-

import gc, time

class OBJ(object):

    pass

 

def show_cycle_reference():

    a, b = OBJ(), OBJ()

    a.attr_b = b

    b.attr_a = a

 

if __name__ == '__main__':

    gc.disable() # 這裡是否disable事實上無所謂

    gc.set_debug(gc.DEBUG_COLLECTABLE | gc.DEBUG_OBJECTS)

    for _ in xrange(1):

        show_cycle_reference()

    gc.collect()

    time.sleep(5)

上面程式碼第13行設定了debug flag,可以打印出collectable物件。另外,只用呼叫一次show_cycle_reference函式就足夠了(這也比objgraph.show_most_common_types方便一點)。在第16行手動呼叫gc.collect(),輸出如下:

Python

 

1

2

3

4

gc: collectable <OBJ 023B46F0>

gc: collectable <OBJ 023B4710>

gc: collectable <dict 023B7AE0>

gc: collectable <dict 023B7930>

注意:只有當物件是unreachable且collectable的時候,在collect的時候才會被輸出,也就是說,如果是reachable,比如被global作用域的變數引用,那麼也是不會輸出的。

通過上面的輸出,我們已經知道OBJ類的例項存在迴圈引用,但是這個時候,obj例項已經被回收了。那麼如果我想通過show_backrefs找出這個引用關係,需要重新呼叫show_cycle_reference函式,然後不呼叫gc.collect,通過show_backrefs 和 by_type繪製。有沒有更好的辦法呢,可以讓我在一次執行中發現迴圈引用,並找出引用鏈?答案就是使用DEBUG_SAVEALL,下面為了展示方便,直接在命令列中操作(當然,使用ipython更好)

Python

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

>>> import gc, objgraph

>>> class OBJ(object):

... pass

...

>>> def show_cycle_reference():

... a, b = OBJ(), OBJ()

... a.attr_b = b

... b.attr_a = a

...

>>> gc.set_debug(gc.DEBUG_SAVEALL| gc.DEBUG_OBJECTS)

>>> show_cycle_reference()

>>> print 'before collect', gc.garbage

before collect []

>>> print gc.collect()

4

>>>

>>> for o in gc.garbage:

... print o

...

<__main__.OBJ object at 0x024BB7D0>

<__main__.OBJ object at 0x02586850>

{'attr_b': <__main__.OBJ object at 0x02586850>}

{'attr_a': <__main__.OBJ object at 0x024BB7D0>}

>>>

>>> objgraph.show_backrefs(objgraph.at(0x024BB7D0), 5, filename = 'obj.dot')

Graph written to obj.dot (13 nodes)

>>>

上面在呼叫gc.collect之前,gc.garbage裡面是空的,由於設定了DEBUG_SAVEALL,那麼呼叫gc.collect時,會將collectable物件放到gc.garbage。此時,物件沒有被釋放,我們就可以直接繪製出引用關係,這裡使用了objgraph.at,當然也可以使用objgraph.by_type, 或者直接從gc.garbage取物件,結果如下:

1089769-20170919090908056-1998847597

出了迴圈引用,可以看見還有兩個引用,gc.garbage與區域性變數o,相信大家也能理解。

消滅迴圈引用

找到迴圈引用關係之後,解除迴圈引用就不是太難的事情,總的來說,有兩種辦法:手動解除與使用weakref。

手動解除很好理解,就是在合適的時機,解除引用關係。比如,前面提到的collections.OrderedDict:

Python

 

1

2

3

4

5

6

7

8

9

>>> root = []

>>> root[:] = [root, root, None]

>>>

>>> root

[[...], [...], None]

>>>

>>> del root[:]

>>> root

[]

更常見的情況,是我們自定義的物件之間存在迴圈引用:要麼是單個物件內的迴圈引用,要麼是多個物件間的迴圈引用,我們看一個單個物件內迴圈引用的例子:

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

class Connection(object):

    MSG_TYPE_CHAT = 0X01

    MSG_TYPE_CONTROL = 0X02

    def __init__(self):

        self.msg_handlers = {

            self.MSG_TYPE_CHAT : self.handle_chat_msg,

            self.MSG_TYPE_CONTROL : self.handle_control_msg

        }

 

    def on_msg(self, msg_type, *args):

        self.msg_handlers[msg_type](*args)

 

    def handle_chat_msg(self, msg):

        pass

 

    def handle_control_msg(self, msg):

        pass

上面的程式碼非常常見,程式碼也很簡單,初始化函式中為每種訊息型別定義響應的處理函式,當訊息到達(on_msg)時根據訊息型別取出處理函式。但這樣的程式碼是存在迴圈引用的,感興趣的讀者可以用objgraph看看引用圖。如何手動解決呢,為Connection增加一個destroy(或者叫clear)函式,該函式將 self.msg_handlers 清空(self.msg_handlers.clear())。當Connection理論上不在被使用的時候呼叫destroy函式即可。

對於多個物件間的迴圈引用,處理方法也是一樣的,就是在“適當的時機”呼叫destroy函式,難點在於什麼是適當的時機

另外一種更方便的方法,就是使用弱引用weakref, weakref是Python提供的標準庫,旨在解決迴圈引用。

weakref模組提供了以下一些有用的API:

(1)weakref.ref(object, callback = None)

建立一個對object的弱引用,返回值為weakref物件,callback: 當object被刪除的時候,會呼叫callback函式,在標準庫logging (__init__.py)中有使用範例。使用的時候要用()解引用,如果referant已經被刪除,那麼返回None。比如下面的例子

 

1

2

3

4

5

6

7

8

9

10

11

12

# -*- coding: utf-8 -*-

import weakref

class OBJ(object):

    def f(self):

        print 'HELLO'

 

if __name__ == '__main__':

    o = OBJ()

    w = weakref.ref(o)

    w().f()

    del o

    w().f()

執行上面的程式碼,第12行會丟擲異常:AttributeError: ‘NoneType’ object has no attribute ‘f’。因為這個時候被引用的物件已經被刪除了

(2)weakref.proxy(object, callback = None)

建立一個代理,返回值是一個weakproxy物件,callback的作用同上。使用的時候直接用 和object一樣,如果object已經被刪除 那麼跑出異常   ReferenceError: weakly-referenced object no longer exists。

 

1

2

3

4

5

6

7

8

9

10

11

12

# -*- coding: utf-8 -*-

import weakref

class OBJ(object):

    def f(self):

        print 'HELLO'

 

if __name__ == '__main__':

    o = OBJ()

    w = weakref.proxy(o)

    w.f()

    del o

    w.f()

注意第10行 12行與weakref.ref示例程式碼的區別

(3)weakref.WeakSet

這個是一個弱引用集合,當WeakSet中的元素被回收的時候,會自動從WeakSet中刪除。WeakSet的實現使用了weakref.ref,當物件加入WeakSet的時候,使用weakref.ref封裝,指定的callback函式就是從WeakSet中刪除。感興趣的話可以直接看原始碼(_weakrefset.py),下面給出一個參考例子:

 

1

2

3

4

5

6

7

8

9

10

11

12

13

# -*- coding: utf-8 -*-

import weakref

class OBJ(object):

    def f(self):

        print 'HELLO'

 

if __name__ == '__main__':

    o = OBJ()

    ws = weakref.WeakSet()

    ws.add(o)

    print len(ws) #  1

    del o

    print len(ws) # 0

(4)weakref.WeakValueDictionary, weakref.WeakKeyDictionary

實現原理和使用方法基本同WeakSet

總結

本文的篇幅略長,首選是簡單介紹了python的記憶體管理,重點介紹了引用計數與垃圾回收,然後闡述Python中記憶體洩露與迴圈引用產生的原因與危害,最後是利用gc、objgraph、weakref等工具來分析並解決記憶體洩露、迴圈引用問題。

references