python--垃圾回收機(jī)制,使用gc、objgraph干掉python內(nèi)存泄露與循環(huán)引用!

目錄

正文

Python使用引用計數(shù)和垃圾回收來做內(nèi)存管理,前面也寫過一遍文章《Python內(nèi)存優(yōu)化》,介紹了在python中,如何profile內(nèi)存使用情況,并做出相應(yīng)的優(yōu)化。本文介紹兩個更致命的問題:內(nèi)存泄露與循環(huán)引用。內(nèi)存泄露是讓所有程序員都聞風(fēng)喪膽的問題,輕則導(dǎo)致程序運(yùn)行速度減慢,重則導(dǎo)致程序崩潰;而循環(huán)引用是使用了引用計數(shù)的數(shù)據(jù)結(jié)構(gòu)、編程語言都需要解決的問題。本文揭曉這兩個問題在python語言中是如何存在的,然后試圖利用gc模塊和objgraph來解決這兩個問題。

注意:本文的目標(biāo)是Cpython,測試代碼都是運(yùn)行在Python2.7。另外,本文不考慮C擴(kuò)展造成的內(nèi)存泄露,這是另一個復(fù)雜且頭疼的問題。

一分鐘版本

(1)python使用引用計數(shù)和垃圾回收來釋放(free)Python對象

(2)引用計數(shù)的優(yōu)點(diǎn)是原理簡單、將消耗均攤到運(yùn)行時;缺點(diǎn)是無法處理循環(huán)引用

(3)Python垃圾回收用于處理循環(huán)引用,但是無法處理循環(huán)引用中的對象定義了del的情況,而且每次回收會造成一定的卡頓

(4)gc module是python垃圾回收機(jī)制的接口模塊,可以通過該module啟停垃圾回收、調(diào)整回收觸發(fā)的閾值、設(shè)置調(diào)試選項(xiàng)

(5)如果沒有禁用垃圾回收,那么Python中的內(nèi)存泄露有兩種情況:要么是對象被生命周期更長的對象所引用,比如global作用域?qū)ο螅灰词茄h(huán)引用中存在del

(6)使用gc module、objgraph可以定位內(nèi)存泄露,定位之后,解決很簡單

(7)垃圾回收比較耗時,因此在對性能和內(nèi)存比較敏感的場景也是無法接受的,如果能解除循環(huán)引用,就可以禁用垃圾回收。

(8)使用gc module的DEBUG選項(xiàng)可以很方便的定位循環(huán)引用,解除循環(huán)引用的辦法要么是手動解除,要么是使用weakref

python內(nèi)存管理

Python中,一切都是對象,又分為mutable和immutable對象。二者區(qū)分的標(biāo)準(zhǔn)在于是否可以原地修改,“原地“”可以理解為相同的地址。可以通過id()查看一個對象的“地址”,如果通過變量修改對象的值,但id沒發(fā)生變化,那么就是mutable,否則就是immutable。比如:

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指向了一個新的對象,因?yàn)閕d發(fā)生了變化。而lst指向的對象(list類型)為可變對象,通過方法(append)可以修改對象的值,同時保證id一致。

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

a1, a2 = [], []
a1 == a2
True
a1 is a2
False

為了避免頻繁的申請、釋放內(nèi)存,避免大量使用的小對象的構(gòu)造析構(gòu),python有一套自己的內(nèi)存管理機(jī)制。在巨著《Python源碼剖析》中有詳細(xì)介紹,在python源碼obmalloc.h中也有詳細(xì)的描述。如下所示:

image

可以看到,python會有自己的內(nèi)存緩沖池(layer2)以及對象緩沖池(layer3)。在Linux上運(yùn)行過Python服務(wù)器的程序都知道,python不會立即將釋放的內(nèi)存歸還給操作系統(tǒng),這就是內(nèi)存緩沖池的原因。而對于可能被經(jīng)常使用、而且是immutable的對象,比如較小的整數(shù)、長度較短的字符串,python會緩存在layer3,避免頻繁創(chuàng)建和銷毀。例如:

a, b = 1, 1
a is b
True
a, b = (), ()
a is b
True
a, b = {}, {}
a is b
False

本文并不關(guān)心python是如何管理內(nèi)存塊、如何管理小對象,感興趣的讀者可以參考伯樂在線csdn上的這兩篇文章。

本文關(guān)心的是,一個普通的對象的生命周期,更明確的說,對象是什么時候被釋放的。當(dāng)一個對象理論上(或者邏輯上)不再被使用了,但事實(shí)上沒有被釋放,那么就存在內(nèi)存泄露;當(dāng)一個對象事實(shí)上已經(jīng)不可達(dá)(unreachable),即不能通過任何變量找到這個對象,但這個對象沒有立即被釋放,那么則可能存在循環(huán)引用。

引用計數(shù)

引用計數(shù)(References count),指的是每個Python對象都有一個計數(shù)器,記錄著當(dāng)前有多少個變量指向這個對象。

將一個對象直接或者間接賦值給一個變量時,對象的計數(shù)器會加1;當(dāng)變量被del刪除,或者離開變量所在作用域時,對象的引用計數(shù)器會減1。當(dāng)計數(shù)器歸零的時候,代表這個對象再也沒有地方可能使用了,因此可以將對象安全的銷毀。Python源碼中,通過Py_INCREF和Py_DECREF兩個宏來管理對象的引用計數(shù),代碼在object.h

#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)對象可以獲得一個對象的引用數(shù)目,返回值是真實(shí)引用數(shù)目加1(加1的原因是obj被當(dāng)做參數(shù)傳入了getrefcount函數(shù)),例如:

import sys
s = 'asdf'
sys.getrefcount(s)
2
a = 1
sys.getrefcount(a)
605

從對象1的引用計數(shù)信息也可以看到,python的對象緩沖池會緩存十分常用的immutable對象,比如這里的整數(shù)1。

引用計數(shù)的優(yōu)點(diǎn)在于原理通俗易懂;且將對象的回收分布在代碼運(yùn)行時:一旦對象不再被引用,就會被釋放掉(be freed),不會造成卡頓。但也有缺點(diǎn):額外的字段(ob_refcnt);頻繁的加減ob_refcnt,而且可能造成連鎖反應(yīng)。但這些缺點(diǎn)跟循環(huán)引用比起來都不算事兒。

什么是循環(huán)引用,就是一個對象直接或者間接引用自己本身,引用鏈形成一個環(huán)。且看下面的例子:

# -*- 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()

運(yùn)行上面的代碼,使用graphviz工具集(本文使用的是dotty)打開生成的兩個文件,direct.dot 和 indirect.dot,得到下面兩個圖

image
image

通過屬性名(attr, attr_a, attr_b)可以很清晰的看出循環(huán)引用是怎么產(chǎn)生的

前面已經(jīng)提到,對于一個對象,當(dāng)沒有任何變量指向自己時,引用計數(shù)降到0,就會被釋放掉。我們以上面左邊那個圖為例,可以看到,紅框里面的OBJ對象想在有兩個引用(兩個入度),分別來自幀對象frame(代碼中,函數(shù)局部空間持有對OBJ實(shí)例的引用)、attr變量。我們再改一下代碼,在函數(shù)運(yùn)行技術(shù)之后看看是否還有OBJ類的實(shí)例存在,引用關(guān)系是怎么樣的:

# -*- 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"

image

修改后的代碼,OBJ實(shí)例(a)存在于函數(shù)的local作用域。因此,當(dāng)函數(shù)調(diào)用結(jié)束之后,來自幀對象frame的引用被解除。從圖中可以看到,當(dāng)前對象的計數(shù)器(入度)為1,按照引用計數(shù)的原理,是不應(yīng)該被釋放的,但這個對象在函數(shù)調(diào)用結(jié)束之后就是事實(shí)上的垃圾,這個時候就需要另外的機(jī)制來處理這種情況了。

python的世界,很容易就會出現(xiàn)循環(huán)引用,比如標(biāo)準(zhǔn)庫Collections中OrderedDict的實(shí)現(xiàn)(已去掉無關(guān)注釋):

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是一個列表,列表里面的元素之自己本身!

垃圾回收

這里強(qiáng)調(diào)一下,本文中的的垃圾回收是狹義的垃圾回收,是指當(dāng)出現(xiàn)循環(huán)引用,引用計數(shù)無計可施的時候采取的垃圾清理算法。

在python中,使用標(biāo)記-清除算法(mark-sweep)和分代(generational)算法來垃圾回收。在《Garbage Collection for Python》一文中有對標(biāo)記回收算法,然后在《Python內(nèi)存管理機(jī)制及優(yōu)化簡析》一文中,有對前文的翻譯,并且有分代回收的介紹。在這里,引用后面一篇文章:

在Python中, 所有能夠引用其他對象的對象都被稱為容器(container). 因此只有容器之間才可能形成循環(huán)引用. Python的垃圾回收機(jī)制利用了這個特點(diǎn)來尋找需要被釋放的對象. 為了記錄下所有的容器對象, Python將每一個 容器都鏈到了一個雙向鏈表中, 之所以使用雙向鏈表是為了方便快速的在容器集合中插入和刪除對象. 有了這個 維護(hù)了所有容器對象的雙向鏈表以后, Python在垃圾回收時使用如下步驟來尋找需要釋放的對象:

  1. 對于每一個容器對象, 設(shè)置一個gc_refs值, 并將其初始化為該對象的引用計數(shù)值.
  2. 對于每一個容器對象, 找到所有其引用的對象, 將被引用對象的gc_refs值減1.
  3. 執(zhí)行完步驟2以后所有gc_refs值還大于0的對象都被非容器對象引用著, 至少存在一個非循環(huán)引用. 因此 不能釋放這些對象, 將他們放入另一個集合.
  4. 在步驟3中不能被釋放的對象, 如果他們引用著某個對象, 被引用的對象也是不能被釋放的, 因此將這些 對象也放入另一個集合中.
  5. 此時還剩下的對象都是無法到達(dá)的對象. 現(xiàn)在可以釋放這些對象了.

關(guān)于分代回收:

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

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

為什么要分代呢,這個算法的根源來自于weak generational hypothesis。這個假說由兩個觀點(diǎn)構(gòu)成:首先是年親的對象通常死得也快,比如大量的對象都存在于local作用域;而老對象則很有可能存活更長的時間,比如全局對象,module, class。

垃圾回收的原理就如上面提示,詳細(xì)的可以看Python源碼,只不過事實(shí)上垃圾回收器還要考慮del,弱引用等情況,會略微復(fù)雜一些。

什么時候會觸發(fā)垃圾回收呢,有三種情況:

(1)達(dá)到了垃圾回收的閾值,Python虛擬機(jī)自動執(zhí)行

(2)手動調(diào)用gc.collect()

(3)Python虛擬機(jī)退出的時候

對于垃圾回收,有兩個非常重要的術(shù)語,那就是reachable與collectable(當(dāng)然還有與之對應(yīng)的unreachable與uncollectable),后文也會大量提及。

reachable是針對python對象而言,如果從根集(root)能到找到對象,那么這個對象就是reachable,與之相反就是unreachable,事實(shí)上就是只存在于循環(huán)引用中的對象,Python的垃圾回收就是針對unreachable對象。

而collectable是針對unreachable對象而言,如果這種對象能被回收,那么是collectable;如果不能被回收,即循環(huán)引用中的對象定義了del, 那么就是uncollectable。Python垃圾回收對uncollectable對象無能為力,會造成事實(shí)上的內(nèi)存泄露。

gc module

這里的gc(garbage collector)是Python 標(biāo)準(zhǔn)庫,該module提供了與上一節(jié)“垃圾回收”內(nèi)容相對應(yīng)的接口。通過這個module,可以開關(guān)gc、調(diào)整垃圾回收的頻率、輸出調(diào)試信息。gc模塊是很多其他模塊(比如objgraph)封裝的基礎(chǔ),在這里先介紹gc的核心API。

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

開啟gc(默認(rèn)情況下是開啟的);關(guān)閉gc;判斷gc是否開啟

gc.collection()

執(zhí)行一次垃圾回收,不管gc是否處于開啟狀態(tài)都能使用

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

設(shè)置垃圾回收閾值; 獲得當(dāng)前的垃圾回收閾值

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

gc.get_objects()

返回所有被垃圾回收器(collector)管理的對象。這個函數(shù)非常基礎(chǔ)!只要python解釋器運(yùn)行起來,就有大量的對象被collector管理,因此,該函數(shù)的調(diào)用比較耗時!

比如,命令行啟動python

import gc
len(gc.get_objects())
3749

gc.get_referents(*obj)

返回obj對象直接指向的對象

gc.get_referrers(*obj)

返回所有直接指向obj的對象

下面的實(shí)例展示了get_referents與get_referrers兩個函數(shù)

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的實(shí)例,執(zhí)行"a.attr = b"之后,a就通過‘’attr“這個屬性指向了b。

gc.set_debug(flags)

設(shè)置調(diào)試選項(xiàng),非常有用,常用的flag組合包含以下

gc.DEBUG_COLLETABLE: 打印可以被垃圾回收器回收的對象

gc.DEBUG_UNCOLLETABLE: 打印無法被垃圾回收器回收的對象,即定義了del的對象

gc.DEBUG_SAVEALL:當(dāng)設(shè)置了這個選項(xiàng),可以被拉起回收的對象不會被真正銷毀(free),而是放到gc.garbage這個列表里面,利于在線上查找問題

內(nèi)存泄露

既然Python中通過引用計數(shù)和垃圾回收來管理內(nèi)存,那么什么情況下還會產(chǎn)生內(nèi)存泄露呢?有兩種情況:

第一是對象被另一個生命周期特別長的對象所引用,比如網(wǎng)絡(luò)服務(wù)器,可能存在一個全局的單例ConnectionManager,管理所有的連接Connection,如果當(dāng)Connection理論上不再被使用的時候,沒有從ConnectionManager中刪除,那么就造成了內(nèi)存泄露。

第二是循環(huán)引用中的對象定義了del函數(shù),這個在《程序員必知的Python陷阱與缺陷列表》一文中有詳細(xì)介紹,簡而言之,如果定義了del函數(shù),那么在循環(huán)引用中Python解釋器無法判斷析構(gòu)對象的順序,因此就不錯處理。

在任何環(huán)境,不管是服務(wù)器,客戶端,內(nèi)存泄露都是非常嚴(yán)重的事情。

如果是線上服務(wù)器,那么一定得有監(jiān)控,如果發(fā)現(xiàn)內(nèi)存使用率超過設(shè)置的閾值則立即報警,盡早發(fā)現(xiàn)些許還有救。當(dāng)然,誰也不希望在線上修復(fù)內(nèi)存泄露,這無疑是給行駛的汽車換輪子,因此盡量在開發(fā)環(huán)境或者壓力測試環(huán)境發(fā)現(xiàn)并解決潛在的內(nèi)存泄露。在這里,發(fā)現(xiàn)問題最為關(guān)鍵,只要發(fā)現(xiàn)了問題,解決問題就非常容易了,因?yàn)榘凑涨懊娴恼f法,出現(xiàn)內(nèi)存泄露只有兩種情況,在第一種情況下,只要在適當(dāng)?shù)臅r機(jī)解除引用就可以了;在第二種情況下,要么不再使用del函數(shù),換一種實(shí)現(xiàn)方式,要么解決循環(huán)引用。

那么怎么查找哪里存在內(nèi)存泄露呢?武器就是兩個庫:gc、objgraph

在上面已經(jīng)介紹了gc這個模塊,理論上,通過gc模塊能夠拿到所有的被garbage collector管理的對象,也能知道對象之間的引用和被引用關(guān)系,就可以畫出對象之間完整的引用關(guān)系圖。但事實(shí)上還是比較復(fù)雜的,因?yàn)樵谶@個過程中一不小心又會引入新的引用關(guān)系,所以,有好的輪子就直接用吧,那就是objgraph

objgraph

objgraph的實(shí)現(xiàn)調(diào)用了gc的這幾個函數(shù):gc.get_objects(), gc.get_referents(), gc.get_referers(),然后構(gòu)造出對象之間的引用關(guān)系。objgraph的代碼和文檔都寫得比較好,建議一讀。

下面先介紹幾個十分實(shí)用的API

def count(typename)

返回該類型對象的數(shù)目,其實(shí)就是通過gc.get_objects()拿到所用的對象,然后統(tǒng)計指定類型的數(shù)目。

def by_type(typename)

返回該類型的對象列表。線上項(xiàng)目,可以用這個函數(shù)很方便找到一個單例對象

def show_most_common_types(limits = 10)

打印實(shí)例最多的前N(limits)個對象,這個函數(shù)非常有用。在《Python內(nèi)存優(yōu)化》一文中也提到,該函數(shù)能發(fā)現(xiàn)可以用slots進(jìn)行內(nèi)存優(yōu)化的對象

def show_growth()

統(tǒng)計自上次調(diào)用以來增加得最多的對象,這個函數(shù)非常有利于發(fā)現(xiàn)潛在的內(nèi)存泄露。函數(shù)內(nèi)部調(diào)用了gc.collect(),因此即使有循環(huán)引用也不會對判斷造成影響。

值得一提,該函數(shù)的實(shí)現(xiàn)非常有意思,簡化后的代碼如下:

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使用了可變參數(shù)作為默認(rèn)形參,這樣很方便記錄上一次的運(yùn)行結(jié)果。在《程序員必知的Python陷阱與缺陷列表》中提到,使用可變對象做默認(rèn)形參是最為常見的python陷阱,但在這里,卻成為了方便的利器!

def show_backrefs()

生產(chǎn)一張有關(guān)objs的引用圖,看出看出對象為什么不釋放,后面會利用這個API來查內(nèi)存泄露。

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

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

找到一條指向obj對象的最短路徑,且路徑的頭部節(jié)點(diǎn)需要滿足predicate函數(shù) (返回值為True)

可以快捷、清晰指出 對象的被引用的情況,后面會展示這個函數(shù)的威力

def show_chain():

將find_backref_chain 找到的路徑畫出來, 該函數(shù)事實(shí)上調(diào)用show_backrefs,只是排除了所有不在路徑中的節(jié)點(diǎn)。

查找內(nèi)存泄露

在這一節(jié),介紹如何利用objgraph來查找內(nèi)存是怎么泄露的

如果我們懷疑一段代碼、一個模塊可能會導(dǎo)致內(nèi)存泄露,那么首先調(diào)用一次obj.show_growth(),然后調(diào)用相應(yīng)的函數(shù),最后再次調(diào)用obj.show_growth(),看看是否有增加的對象。比如下面這個簡單的例子:

# -*- 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()

運(yùn)行結(jié)果(我們只關(guān)心后一次show_growth的結(jié)果)如下

wrapper_descriptor 1073 +13
member_descriptor 204 +5
getset_descriptor 168 +5
weakref 338 +3
dict 458 +3
OBJ 1 +1

代碼很簡單,函數(shù)開始的時候講對象加入了global作用域的_cache列表,然后期望是在函數(shù)退出之前從_cache刪除,但是由于提前返回或者異常,并沒有執(zhí)行到最后的remove語句。從運(yùn)行結(jié)果可以發(fā)現(xiàn),調(diào)用函數(shù)之后,增加了一個類OBJ的實(shí)例,然而理論上函數(shù)調(diào)用結(jié)束之后,所有在函數(shù)作用域(local)中聲明的對象都改被銷毀,因此這里就存在內(nèi)存泄露。

當(dāng)然,在實(shí)際的項(xiàng)目中,我們也不清楚泄露是在哪段代碼、哪個模塊中發(fā)生的,而且往往是發(fā)生了內(nèi)存泄露之后再去排查,這個時候使用obj.show_most_common_types就比較合適了,如果一個自定義的類的實(shí)例數(shù)目特別多,那么就可能存在內(nèi)存泄露。如果在壓力測試環(huán)境,停止壓測,調(diào)用gc.collet,然后再用obj.show_most_common_types查看,如果對象的數(shù)目沒有相應(yīng)的減少,那么肯定就是存在泄露。

當(dāng)我們定位了哪個對象發(fā)生了內(nèi)存泄露,那么接下來就是分析怎么泄露的,引用鏈?zhǔn)窃趺礃拥模@個時候就該show_backrefs出馬了,還是以之前的代碼為例,稍加修改:

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查看內(nèi)存泄露

注意,上面的代碼中,max_depth參數(shù)非常關(guān)鍵,如果這個參數(shù)太小,那么看不到完整的引用鏈,如果這個參數(shù)太大,運(yùn)行的時候又非常耗時間。

然后打開dot文件,結(jié)果如下

image

可以看到泄露的對象(紅框表示),是被一個叫_cache的list所引用,而_cache又是被main這個module所引用。

對于示例代碼,dot文件的結(jié)果已經(jīng)非常清晰,但是對于真實(shí)項(xiàng)目,引用鏈中的節(jié)點(diǎn)可能成百上千,看起來非常頭大,下面用tornado起一個最最簡單的web服務(wù)器(代碼不知道來自哪里,且沒有內(nèi)存泄露,這里只是為了顯示引用關(guān)系),然后繪制socket的引用關(guān)關(guān)系圖,代碼和引用關(guān)系圖如下:

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()

可見,代碼越復(fù)雜,相互之間的引用關(guān)系越多,show_backrefs越難以看懂。這個時候就使用show_chain和find_backref_chain吧,這種方法,在官方文檔也是推薦的,我們稍微改改代碼,結(jié)果如下:

image
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'
    )

image

上面介紹了內(nèi)存泄露的第一種情況,對象被“非期望”地引用著。下面看看第二種情況,循環(huán)引用中的del, 看下面的代碼:

# -*- 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')

上面的代碼存在循環(huán)引用,而且OBJ類定義了del函數(shù)。如果沒有定義del函數(shù),那么上述的代碼會報錯, 因?yàn)間c.collect會將循環(huán)引用刪除,objgraph.by_type('OBJ')返回空列表。而因?yàn)槎x了del函數(shù),gc.collect也無能為力,結(jié)果如下:

image

從圖中可以看到,對于這種情況,還是比較好辨識的,因?yàn)閛bjgraph將del函數(shù)用特殊顏色標(biāo)志出來,一眼就看見了。另外,可以看見gc.garbage(類型是list)也引用了這兩個對象,原因在document中有描述,當(dāng)執(zhí)行垃圾回收的時候,會將定義了del函數(shù)的類實(shí)例(被稱為uncollectable object)放到gc.garbage列表,因此,也可以直接通過查看gc.garbage來找出定義了del的循環(huán)引用。在這里,通過增加extra_ignore來排除gc.garbage的影響:

將上述代碼的最后一行改成:

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

image

另外,也可以設(shè)置DEBUG_UNCOLLECTABLE 選項(xiàng),直接將uncollectable對象輸出到標(biāo)準(zhǔn)輸出,而不是放到gc.garbage

循環(huán)引用

除非定義了del方法,那么循環(huán)引用也不是什么萬惡不赦的東西,因?yàn)槔厥掌骺梢蕴幚硌h(huán)引用,而且不準(zhǔn)是python標(biāo)準(zhǔn)庫還是大量使用的第三方庫,都可能存在循環(huán)引用。如果存在循環(huán)引用,那么Python的gc就必須開啟(gc.isenabled()返回True),否則就會內(nèi)存泄露。但是在某些情況下,我們還是不希望有g(shù)c,比如對內(nèi)存和性能比較敏感的應(yīng)用場景,在這篇文章中,提到instagram通過禁用gc,性能提升了10%;另外,在一些應(yīng)用場景,垃圾回收帶來的卡頓也是不能接受的,比如RPG游戲。從前面對垃圾回收的描述可以看到,執(zhí)行一次垃圾回收是很耗費(fèi)時間的,因?yàn)樾枰闅v所有被collector管理的對象(即使很多對象不屬于垃圾)。因此,要想禁用GC,就得先徹底干掉循環(huán)引用。

同內(nèi)存泄露一樣,解除循環(huán)引用的前提是定位哪里出現(xiàn)了循環(huán)引用。而且,如果需要在線上應(yīng)用關(guān)閉gc,那么需要自動、持久化的進(jìn)行檢測。下面介紹如何定位循環(huán)引用,以及如何解決循環(huán)引用。

定位循環(huán)引用

這里還是是用GC模塊和objgraph來定位循環(huán)引用。需要注意的事,一定要先禁用gc(調(diào)用gc.disable()), 防止誤差。

這里利用之前介紹循環(huán)引用時使用過的例子: a, b兩個OBJ對象形成循環(huán)引用

# -*- 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)

運(yùn)行結(jié)果(部分):

wrapper_descriptor 1060
dict 555
OBJ 100

上面的代碼中使用的是show_most_common_types,而沒有使用show_growth(因?yàn)間rowth會手動調(diào)用gc.collect()),通過結(jié)果可以看到,內(nèi)存中現(xiàn)在有100個OBJ對象,符合預(yù)期。當(dāng)然這些OBJ對象沒有在函數(shù)調(diào)用后被銷毀,不一定是循環(huán)引用的問題,也可能是內(nèi)存泄露,比如前面OBJ對象被global作用域中的_cache引用的情況。怎么排除是否是被global作用域的變量引用的情況呢,方法還是objgraph.find_backref_chain(obj),在doc中指出,如果找不到符合條件的應(yīng)用鏈(chain),那么返回[obj],稍微修改上面的代碼:

image
# -*- 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

純循環(huán)引用判斷

上面的代碼輸出:

[<main.OBJ object at 0x0244F810>]

驗(yàn)證了我們的想法,OBJ對象不是被global作用域的變量所引用。

在實(shí)際項(xiàng)目中,不大可能到處用objgraph.show_most_common_types或者objgraph.by_type來排查循環(huán)引用,效率太低。有沒有更好的辦法呢,有的,那就是使用gc模塊的debug 選項(xiàng)。在前面介紹gc模塊的時候,就介紹了gc.DEBUG_COLLECTABLE 選項(xiàng),我們來試試:

# -*- 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事實(shí)上無所謂
    gc.set_debug(gc.DEBUG_COLLECTABLE | gc.DEBUG_OBJECTS)
    for _ in xrange(1):
        show_cycle_reference()
    gc.collect()
    time.sleep(5)

上面代碼第13行設(shè)置了debug flag,可以打印出collectable對象。另外,只用調(diào)用一次show_cycle_reference函數(shù)就足夠了(這也比objgraph.show_most_common_types方便一點(diǎn))。在第16行手動調(diào)用gc.collect(),輸出如下:

gc: collectable <OBJ 023B46F0>
gc: collectable <OBJ 023B4710>
gc: collectable <dict 023B7AE0>
gc: collectable <dict 023B7930>

注意:只有當(dāng)對象是unreachable且collectable的時候,在collect的時候才會被輸出,也就是說,如果是reachable,比如被global作用域的變量引用,那么也是不會輸出的。

通過上面的輸出,我們已經(jīng)知道OBJ類的實(shí)例存在循環(huán)引用,但是這個時候,obj實(shí)例已經(jīng)被回收了。那么如果我想通過show_backrefs找出這個引用關(guān)系,需要重新調(diào)用show_cycle_reference函數(shù),然后不調(diào)用gc.collect,通過show_backrefs 和 by_type繪制。有沒有更好的辦法呢,可以讓我在一次運(yùn)行中發(fā)現(xiàn)循環(huán)引用,并找出引用鏈?答案就是使用DEBUG_SAVEALL,下面為了展示方便,直接在命令行中操作(當(dāng)然,使用ipython更好)

> >>> 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)
> >>>

上面在調(diào)用gc.collect之前,gc.garbage里面是空的,由于設(shè)置了DEBUG_SAVEALL,那么調(diào)用gc.collect時,會將collectable對象放到gc.garbage。此時,對象沒有被釋放,我們就可以直接繪制出引用關(guān)系,這里使用了objgraph.at,當(dāng)然也可以使用objgraph.by_type, 或者直接從gc.garbage取對象,結(jié)果如下:

image

出了循環(huán)引用,可以看見還有兩個引用,gc.garbage與局部變量o,相信大家也能理解。

消滅循環(huán)引用

找到循環(huán)引用關(guān)系之后,解除循環(huán)引用就不是太難的事情,總的來說,有兩種辦法:手動解除與使用weakref。

手動解除很好理解,就是在合適的時機(jī),解除引用關(guān)系。比如,前面提到的collections.OrderedDict:

> >>> root = []
> >>> root[:] = [root, root, None]
> >>>
> **>>> root**
> **[[...], [...], None]**
> >>>
> >>> **del root[:]**
> >>> root
> []

更常見的情況,是我們自定義的對象之間存在循環(huán)引用:要么是單個對象內(nèi)的循環(huán)引用,要么是多個對象間的循環(huán)引用,我們看一個單個對象內(nèi)循環(huán)引用的例子:

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

上面的代碼非常常見,代碼也很簡單,初始化函數(shù)中為每種消息類型定義響應(yīng)的處理函數(shù),當(dāng)消息到達(dá)(on_msg)時根據(jù)消息類型取出處理函數(shù)。但這樣的代碼是存在循環(huán)引用的,感興趣的讀者可以用objgraph看看引用圖。如何手動解決呢,為Connection增加一個destroy(或者叫clear)函數(shù),該函數(shù)將 self.msg_handlers 清空(self.msg_handlers.clear())。當(dāng)Connection理論上不在被使用的時候調(diào)用destroy函數(shù)即可。

對于多個對象間的循環(huán)引用,處理方法也是一樣的,就是在“適當(dāng)?shù)臅r機(jī)”調(diào)用destroy函數(shù),難點(diǎn)在于什么是適當(dāng)?shù)臅r機(jī)

另外一種更方便的方法,就是使用弱引用weakref, weakref是Python提供的標(biāo)準(zhǔn)庫,旨在解決循環(huán)引用。

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

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

創(chuàng)建一個對object的弱引用,返回值為weakref對象,callback: 當(dāng)object被刪除的時候,會調(diào)用callback函數(shù),在標(biāo)準(zhǔn)庫logging (init.py)中有使用范例。使用的時候要用()解引用,如果referant已經(jīng)被刪除,那么返回None。比如下面的例子

# -*- 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()

運(yùn)行上面的代碼,第12行會拋出異常:AttributeError: 'NoneType' object has no attribute 'f'。因?yàn)檫@個時候被引用的對象已經(jīng)被刪除了

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

創(chuàng)建一個代理,返回值是一個weakproxy對象,callback的作用同上。使用的時候直接用 和object一樣,如果object已經(jīng)被刪除 那么跑出異常 ReferenceError: weakly-referenced object no longer exists。

# -*- 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示例代碼的區(qū)別

(3)weakref.WeakSet

這個是一個弱引用集合,當(dāng)WeakSet中的元素被回收的時候,會自動從WeakSet中刪除。WeakSet的實(shí)現(xiàn)使用了weakref.ref,當(dāng)對象加入WeakSet的時候,使用weakref.ref封裝,指定的callback函數(shù)就是從WeakSet中刪除。感興趣的話可以直接看源碼(_weakrefset.py),下面給出一個參考例子:

# -*- 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

實(shí)現(xiàn)原理和使用方法基本同WeakSet

總結(jié)

本文的篇幅略長,首選是簡單介紹了python的內(nèi)存管理,重點(diǎn)介紹了引用計數(shù)與垃圾回收,然后闡述Python中內(nèi)存泄露與循環(huán)引用產(chǎn)生的原因與危害,最后是利用gc、objgraph、weakref等工具來分析并解決內(nèi)存泄露、循環(huán)引用問題。

references

Garbage Collector Interface

objgraph

Garbage Collection for Python

禁用Python的GC機(jī)制后,Instagram性能提升10%

Python內(nèi)存管理機(jī)制及優(yōu)化簡析

library weakref
本文版權(quán)歸作者xybaby(博文地址:http://www.cnblogs.com/xybaby/)所有,歡迎轉(zhuǎn)載和商用,請在文章頁面明顯位置給出原文鏈接并保留此段聲明,否則保留追究法律責(zé)任的權(quán)利,其他事項(xiàng),可留言咨詢。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,572評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,071評論 3 414
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,409評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,569評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,360評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 54,895評論 1 321
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,979評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,123評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,643評論 1 333
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,559評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,742評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,250評論 5 356
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 43,981評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,363評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,622評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,354評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,707評論 2 370