1. 程式人生 > 實用技巧 >深度解析Python垃圾回收機制(超級詳細)

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

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

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 的效能。