深度解析Python垃圾回收機制(超級詳細)

2020-07-16 10:05:02
我們知道,目前的計算機都採用的是圖靈機架構,其本質就是用一條無限長的紙帶,對應今天的記憶體。隨後在工程學的推演中,逐漸出現了暫存器、易失性記憶體(記憶體)以及永久性記憶體(硬碟)等產品。由於不同的記憶體,其速度越快,單位價格也就越昂貴,因此,妥善利用好每一寸告訴記憶體的空間,永遠是系統設計的一個核心。

Python 程式在執行時,需要在記憶體中開闢出一塊空間,用於存放執行時產生的臨時變數,計算完成後,再將結果輸出到永久性記憶體中。但是當資料量過大,或者記憶體空間管理不善,就很容易出現記憶體溢位的情況,程式可能會被作業系統終止。

而對於伺服器這種用於永不中斷的系統來說,記憶體管理就顯得更為重要了,不然很容易引發記憶體漏失。

這裡的記憶體漏失是指程式本身沒有設計好,導致程式未能釋放已不再使用的記憶體,或者直接失去了對某段記憶體的控制,造成了記憶體的浪費。

那麼,對於不會再用到的記憶體空間,Python 是通過什麼機制來管理的呢?其實在前面章節已大致接觸過,就是參照計數機制。

Python參照計數機制

在學習 Python 的整個過程中,我們一直在強調,Python 中一切皆物件,也就是說,在 Python 中你用到的一切變數,本質上都是類物件。

那麼,如何知道一個物件永遠都不能再使用了呢?很簡單,就是當這個物件的參照計數值為 0 時,說明這個物件永不再用,自然它就變成了垃圾,需要被回收。

舉個例子:
import os
import psutil

# 顯示當前 python 程式佔用的記憶體大小
def show_memory_info(hint):
    pid = os.getpid()
    p = psutil.Process(pid)
   
    info = p.memory_full_info()
    memory = info.uss / 1024. / 1024
    print('{} memory used: {} MB'.format(hint, memory))
def func():
    show_memory_info('initial')
    a = [i for i in range(10000000)]
    show_memory_info('after a created')

func()
show_memory_info('finished')
輸出結果為:

initial memory used: 47.19140625 MB
after a created memory used: 433.91015625 MB
finished memory used: 48.109375 MB

注意,執行此程式之前,需安裝 psutil 模組(獲取系統資訊的模組),可使用 pip 命令直接安裝,執行命令為 $pip install psutil,如果遇到 Permission denied 安裝失敗,請加上 sudo 重試。

可以看到,當呼叫函數 func() 且列表 a 被建立之後,記憶體占用迅速增加到了 433 MB,而在函數呼叫結束後,記憶體則返回正常。這是因為,函數內部宣告的列表 a 是區域性變數,在函數返回後,區域性變數的參照會登出掉,此時列表 a 所指代物件的參照計數為 0,Python 便會執行垃圾回收,因此之前佔用的大量記憶體就又回來了。

明白了這個原理後,稍微修改上面的程式碼,如下所示:
def func():
    show_memory_info('initial')
    global a
    a = [i for i in range(10000000)]
    show_memory_info('after a created')

func()
show_memory_info('finished')
輸出結果為:

initial memory used: 48.88671875 MB
after a created memory used: 433.94921875 MB
finished memory used: 433.94921875 MB

上面這段程式碼中,global a 表示將 a 宣告為全域性變數,則即使函數返回後,列表的參照依然存在,於是 a 物件就不會被當做垃圾回收掉,依然佔用大量記憶體。

同樣,如果把生成的列表返回,然後在主程式中接收,那麼參照依然存在,垃圾回收也不會被觸發,大量記憶體仍然被佔用著:
def func():
    show_memory_info('initial')
    a = [i for i in derange(10000000)]
    show_memory_info('after a created')
    return a

a = func()
show_memory_info('finished')
輸出結果為:

initial memory used: 47.96484375 MB
after a created memory used: 434.515625 MB
finished memory used: 434.515625 MB


以上最常見的幾種情況,下面由表及里,深入看一下 Python 內部的參照計數機制。先來分析一段程式碼:
import sys
a = []
# 兩次參照,一次來自 a,一次來自 getrefcount
print(sys.getrefcount(a))
def func(a):
    # 四次參照,a,python 的函數呼叫棧,函數引數,和 getrefcount
    print(sys.getrefcount(a))
func(a)
# 兩次參照,一次來自 a,一次來自 getrefcount,函數 func 呼叫已經不存在
print(sys.getrefcount(a))
輸出結果為:

2
4
2

注意,sys.getrefcount() 函數用於檢視一個變數的參照次數,不過別忘了,getrefcount 本身也會引入一次計數。

另一個要注意的是,在函數呼叫發生的時候,會產生額外的兩次參照,一次來自函數棧,另一個是函數引數。
import sys
a = []
print(sys.getrefcount(a)) # 兩次
b = a
print(sys.getrefcount(a)) # 三次
c = b
d = b
e = c
f = e
g = d
print(sys.getrefcount(a)) # 八次
輸出結果為:

2
3
8

分析一下這段程式碼,a、b、c、d、e、f、g 這些變數全部指代的是同一個物件,而 sys.getrefcount() 函數並不是統計一個指標,而是要統計一個物件被參照的次數,所以最後一共會有 8 次參照。

理解除參照這個概念後,參照釋放是一種非常自然和清晰的思想。相比 C 語言中需要使用 free 去手動釋放記憶體,Python 的垃圾回收在這裡可以說是省心省力了。

不過,有讀者還是會好奇,如果想手動釋放記憶體,應該怎麼做呢?方法同樣很簡單,只需要先呼叫 del a 來刪除一個物件,然後強制呼叫 gc.collect() 即可手動啟動垃圾回收。例如:
import gc

show_memory_info('initial')
a = [i for i in range(10000000)]
show_memory_info('after a created')
del a
gc.collect()
show_memory_info('finish')
print(a)
輸出結果為:

initial memory used: 48.1015625 MB
after a created memory used: 434.3828125 MB
finish memory used: 48.33203125 MB
NameError Traceback (most recent call last)
<ipython-input-12-153e15063d8a> in <module>
     11
     12 show_memory_info('finish')
---> 13 print(a)
NameError: name 'a' is not defined

是不是覺得垃圾回收非常簡單呢?這裡再問大家一個問題:參照次數為 0 是垃圾回收啟動的充要條件嗎?還有沒有其他可能性呢?

其實,參照計數是其中最簡單的實現,參照計數並非充要條件,它只能算作充分非必要條件,至於其他的可能性,下面所講的迴圈參照正是其中一種。

迴圈參照

首先思考一個問題,如果有兩個物件,之間互相參照,且不再被別的物件所參照,那麼它們應該被垃圾回收嗎?

舉個例子:
def func():
    show_memory_info('initial')
    a = [i for i in range(10000000)]
    b = [i for i in range(10000000)]
    show_memory_info('after a, b created')
    a.append(b)
    b.append(a)

func()
show_memory_info('finished')
輸出結果為:

initial memory used: 47.984375 MB
after a, b created memory used: 822.73828125 MB
finished memory used: 821.73046875 MB

程式中,a 和 b 互相參照,並且作為區域性變數在函數 func 呼叫結束後,a 和 b 這兩個指標從程式意義上已經不存在,但從輸出結果中看到,依然有記憶體占用,這是為什麼呢?因為互相參照導致它們的參照數都不為 0。

試想一下,如果這段程式碼出現在生產環境中,哪怕 a 和 b 一開始佔用的空間不是很大,但經過長時間執行後,Python 所佔用的記憶體一定會變得越來越大,最終撐爆伺服器,後果不堪設想。

有讀者可能會說,互相參照還是很容易被發現的呀,問題不大。可是,更隱蔽的情況是出現一個參照環,在工程程式碼比較複雜的情況下,參照環真不一定能被輕易發現。那麼應該怎麼做呢?

事實上,Python 本身能夠處理這種情況,前面剛剛講過,可以顯式呼叫 gc.collect() 來啟動垃圾回收,例如:
import gc

def func():
    show_memory_info('initial')
    a = [i for i in range(10000000)]
    b = [i for i in range(10000000)]
    show_memory_info('after a, b created')
    a.append(b)
    b.append(a)

func()
gc.collect()
show_memory_info('finished')
輸出結果為:

initial memory used: 49.51171875 MB
after a, b created memory used: 824.1328125 MB
finished memory used: 49.98046875 MB


事實上,Python 使用標記清除(mark-sweep)演算法和分代收集(generational),來啟用針對迴圈參照的自動垃圾回收。

先來看標記清除演算法。我們先用圖論來理解不可達的概念。對於一個有向圖,如果從一個節點出發進行遍歷,並標記其經過的所有節點;那麼,在遍歷結束後,所有沒有被標記的節點,我們就稱之為不可達節點。顯而易見,這些節點的存在是沒有任何意義的,自然的,我們就需要對它們進行垃圾回收。

當然,每次都遍歷全圖,對於 Python 而言是一種巨大的效能浪費。所以,在 Python 的垃圾回收實現中,標記清除演算法使用雙向連結串列維護了一個資料結構,並且只考慮容器類的物件(只有容器類物件才有可能產生迴圈參照)。

而分代收集演算法,則是將 Python 中的所有物件分為三代。剛剛創立的物件是第 0 代;經過一次垃圾回收後,依然存在的物件,便會依次從上一代挪到下一代。而每一代啟動自動垃圾回收的閾值,則是可以單獨指定的。當垃圾回收器中新增物件減去刪除物件達到相應的閾值時,就會對這一代物件啟動垃圾回收。

事實上,分代收集基於的思想是,新生的物件更有可能被垃圾回收,而存活更久的物件也有更高的概率繼續存活。因此,通過這種做法,可以節約不少計算量,從而提高 Python 的效能。

由於篇幅有限,這裡不再對標記清除演算法和分代收集演算法的具體實現所詳細介紹,有興趣的讀者可自行百度搜尋。