1. 程式人生 > >Python垃圾回收與記憶體洩露

Python垃圾回收與記憶體洩露

    Python是面向物件、高階程式語言,其世界裡萬物皆物件,當我們編寫的程式執行時,程式碼中定義的物件在實體記憶體中會佔用相應的空間。現在流行的高階語言如Java,C#等都採用了垃圾收集機制自動管理記憶體使用,而不像C,C++需要使用者自己分配、釋放記憶體。自己管理記憶體的優點是自由靈活,可以任意申請記憶體,但存在致命的缺點是可能會造成記憶體洩露。

    Python直譯器核心採用記憶體池方式管理實體記憶體,當建立新物件時,直譯器在預先申請的實體記憶體塊上分配相應的空間給物件使用,這樣可以避免頻繁的分配和釋放實體記憶體。那麼這些記憶體在什麼時候釋放呢?這涉及到Python物件的引用計數和垃圾回收。

1. 相關概念

1.1 什麼是垃圾

    先看一個例子。

# -*- coding: utf8 -*-

class A(object):
    def __init__(self):
        self.data = [x for x in range(10000)]
        self.child = None
       
def ref():
    a1 = A()
    a2 = A()
    
    a1.child = a2

    在上述程式碼中,定義了類A,以及ref函式。在ref函式中,申明瞭A的兩個例項物件

,並且變數a1、a2分別指向這兩個物件,且a1引用了a2指向的物件。當ref函式結束後,也就是a1和a2離開了作用域,在python直譯器內部無任何地方引用這兩個物件,因此a1、a2指向的兩個物件變成“垃圾”物件。這些物件也就是所謂的記憶體垃圾,python直譯器有一套垃圾回收機制,確保記憶體中無用物件及其空間及時被清理。   

1.2 什麼是垃圾回收

    Python垃圾回收是指記憶體不再使用時的釋放和回收過程。Python通過兩種機制實現垃圾回收:引用計數能解決迴圈引用問題的垃圾收集器。

garbage collection

    The process of freeing memory when it is not used anymore. Python performs garbage collection via reference counting and a cyclic garbage collector that is able to detect and break reference cycles.

                                                                                                                                                  —— from python doc

1.2.1 引用計數

    引用計數是每個python物件的一個屬性,該屬性記錄著有多少變數引用(指向)了該物件,該屬性就稱之為引用計數。將一個物件直接或者間接賦值給一個變數時,物件的計數器會加1 ;當變數被del刪除,或者離開變數所在作用域時,物件的引用計數器會減 1。當引用計數歸零時,代表無任何地方引用該物件,直譯器將該物件安全的銷燬。我們可以通過sys模組getrefcount()函式獲取物件當前的引用計數。

1.2.2 垃圾收集器

    引用計數存在一個比較嚴重的缺陷是,無法及時回收存在迴圈引用物件。只有容器物件才會形成迴圈引用,比如list、class、deque、dict、set等都屬於容器型別,那麼什麼是迴圈引用?看下下面這個例子:

# -*- coding: utf8 -*-

class A(object):
    def __init__(self):
        self.data = [x for x in range(10000)]
        self.child = None
        
def cycle_ref():
    a1 = A()
    a2 = A()

    a1.child = a2
    a2.child = a1

    上述程式碼cycle_ref函式,a1、a2指向的物件即存在迴圈引用。迴圈引用即兩個物件互相引用對方,迴圈引用可能帶來記憶體洩露問題。當函式cycle_ref結束後,a1、a2離開其作用域,因此他們指向的物件的引用計數減 1。但由於互相引用,兩個物件的引用計數始終為 1,因此直譯器不會對其進行垃圾回收,從而可能造成記憶體洩露。比如執行下面的粗暴程式碼,可以非常明顯地看到記憶體一直在飆升:

# -*- coding: utf8 -*-

class A(object):
    def __init__(self):
        self.data = [x for x in range(100000)]
        self.child = None

    def __del__(self):
        pass

def cycle_ref():
    a1 = A()
    a2 = A()

    a1.child = a2
    a2.child = a1

if __name__ == '__main__':
    import time
    while True:
        time.sleep(0.5)
        cycle_ref()

    對於迴圈引用帶來的問題,python直譯器提供了垃圾收集器(gc)模組,gc使用分代回收演算法回收垃圾。

    所謂分代回收,是一種以空間換時間的操作方式。Python將記憶體根據物件的存活時間劃分為不同的集合,每個集合稱為一個代,Python將記憶體分為了三個generation(代),分別為年輕代(第 0 代)、中年代(第 1 代)、老年代(第 2 代),他們對應的是3個連結串列,它們的垃圾收集頻率與物件的存活時間的增大而減小。新建立的物件都會分配在第 0 代,年輕代連結串列的總數達到設定閾值時,Python垃圾收集機制就會被觸發,把那些可以被回收的物件回收掉,而那些不會回收的物件就會被移到中年代去,依此類推。老年代中的物件是存活時間最久的物件,甚至是存活於整個系統的生命週期內。同時,分代回收是建立在標記清除技術基礎之上。分代回收同樣作為Python的輔助垃圾收集技術處理那些容器物件。

    分代回收在實現上,支援垃圾收集的物件(主要是容器物件),其核心的PyTypeObject結構體物件的tp_flags變數的Py_TYFLAGS_HAVE_GC位為1。凡是該標記位為1的物件,其底層實體記憶體的分配使用_PyObject_GC_Malloc函式,其他使用PyObject_Malloc函式。_PyObject_GC_Malloc本質上也是呼叫PyObject_Malloc函式在記憶體池上分配記憶體,但是會多分配PyGC_Head結構體大小的記憶體,該PyGC_Head位於物件實際記憶體的前面。PyGC_Head有一個gc_refs屬性,垃圾收集器通過判斷gc_refs值來實現垃圾回收。

    所有支援垃圾收集的物件,在建立時都會被新增到一個gc雙向連結串列,也就是前面所說的第 0 代的連結串列頭部(直譯器c原始碼中的_PyGC_generation0)。另外還有兩個gc雙向連結串列,儲存了第 1 代第 2 代物件。垃圾收集主要流程如下:

1. 對於每一個容器物件,設定一個gc_refs值,並將其初始化為該物件的引用計數值。

2. 對於每一個容器物件,找到所有其引用的物件,將被引用物件的gc_refs值減1。

3. 執行完步驟2以後所有gc_refs值還大於0的物件都被非容器物件引用著,至少存在一個非迴圈引用。因此不能釋放這些物件,將他們放入另一個集合。

4. 在步驟3中不能被釋放的物件,如果他們引用著某個物件,被引用的物件也是不能被釋放的, 因此將這些物件也放入另一個集合中。

5. 此時還剩下的物件都是無法到達(unreachable)的物件, 現在可以釋放這些物件了。

    在迴圈引用中,對於unreachable、但collectable的物件,Python的gc垃圾回收機制能夠定時自動回收這些物件。但是如果物件定義了__del__方法,這些物件變為uncollectable,垃圾回收機制無法收集這些物件,這也就是上面程式碼發生記憶體洩露的原因。

    Python直譯器標準庫對外暴露的gc模組,提供了對內部垃圾收集的訪問及配置等介面,比如開啟或關閉gc、設定回收閾值、獲取物件的引用物件等。在需要的地方,我們可以手動執行垃圾回收,及時清理不必要的記憶體物件。

2. 解決記憶體洩露

    到這裡,已經明確瞭如果存在迴圈引用,並且被迴圈引用的物件定義了__del__方法,就會發生記憶體洩露。如果我們的程式碼無法避免迴圈引用,但只要沒有定義__del__方法,並且保證gc模組被開啟,就不會發生記憶體洩露。

    但是由於gc垃圾收集機制,要遍歷所有被垃圾收集器管理的python物件(包括垃圾和非垃圾物件),該過程比較耗時可能會造成程式卡頓,會對某些對記憶體、cpu要求較高的場景造成效能影響。那怎麼才能優雅地避免記憶體洩露呢?

2.1 編寫安全的程式碼

    前面提到過,如果被迴圈引用的物件未定義__del__方法,就不會發生記憶體洩露,因為直譯器的gc機制確保了垃圾物件的定時回收。如果被迴圈引用的物件定義了__del__方法,但是隻要編寫足夠安全的程式碼,也可以保證不發生記憶體洩露。比如對於上面發生記憶體洩露的cycle_ref函式,在函式結束前解除迴圈引用,即可解決記憶體洩露問題。

def cycle_ref():
    a1 = A()
    a2 = A()

    a1.child = a2
    a2.child = a1

    # 解除迴圈引用,避免記憶體洩露
    a1.child  = None
    a2.child  = None

   對於上述方法,我們有可能會忘記那一兩行無關緊要的程式碼而造成災難性後果,畢竟老虎也有打盹的時候。那怎麼辦?不要著急,Python已經為我們考慮到這點:弱引用。

2.2 弱引用

    Python標準庫提供了weakref模組,弱引用不會在引用計數中計數,其主要目的是解決迴圈引用。並非所有的物件都支援weakref,例如list和dict就不支援。下面是weakref比較常用的方法:

1. class weakref.ref(object[, callback]) :建立一個弱引用物件,object是被引用的物件,callback是回撥函式(當被引用物件被刪除時,呼叫該回調函式)

2.weakref.proxy(object[, callback]):建立一個用弱引用實現的代理物件,引數同上

3.weakref.getweakrefcount(object) :獲取物件object關聯的弱引用物件數

4.weakref.getweakrefs(object):獲取object關聯的弱引用物件列表

5.class weakref.WeakKeyDictionary([dict]):建立key為弱引用物件的字典

6.class weakref.WeakValueDictionary([dict]):建立value為弱引用物件的字典

7.class weakref.WeakSet([elements]):建立成員為弱引用物件的集合物件

    同樣對於上面發生記憶體洩露的cycle_ref函式,使用weakref稍加改造,便可更安全地解決記憶體洩露問題:

# -*- coding: utf8 -*-
import weakref

class A(object):
    def __init__(self):
        self.data = [x for x in range(100000)]
        self.child = None

    def __del__(self):
        pass

def cycle_ref():
    a1 = A()
    a2 = A()

    a1.child = weakref.proxy(a2)
    a2.child = weakref.proxy(a1)

if __name__ == '__main__':
    import time
    while True:
        time.sleep(0.5)
        cycle_ref()