解析 Python 垃圾回收機制

解析 Python 垃圾回收機制

眾所周知,我們當代的計算機都是圖靈機架構。圖靈機架構的本質,就是一條無限長的紙帶,對應著我們今天的存儲器。在工程學的演化中,逐漸出現了寄存器、易失性存儲器(內存)和永久性存儲器(硬盤)等產品。其實,這本身來自一個矛盾:速度越快的存儲器,單位價格也越昂貴。因此,妥善利用好每一寸高速存儲器的空間,永遠是系統設計的一個核心。

回到 Python 應用層。

我們知道,Python 程序在運行的時候,需要在內存中開闢出一塊空間,用於存放運行時產生的臨時變量;計算完成後,再將結果輸出到永久性存儲器中。如果數據量過大,內存空間管理不善就很容易出現 OOM(out of memory),俗稱爆內存,程序可能被操作系統中止。

而對於服務器,這種設計為永不中斷的系統來說,內存管理則顯得更為重要,不然很容易引發內存洩漏。什麼是內存洩漏呢?

  • 這裡的洩漏,並不是說你的內存出現了信息安全問題,被惡意程序利用了,而是指程序本身沒有設計好,導致程序未能釋放已不再使用的內存。
  • 內存洩漏也不是指你的內存在物理上消失了,而是意味著代碼在分配了某段內存後,因為設計錯誤,失去了對這段內存的控制,從而造成了內存的浪費。

那麼,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

通過這個示例,你可以看到,調用函數 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 聲明為全局變量。那麼,即使函數返回後,列表的引用依然存在,於是對象就不會被垃圾回收掉,依然佔用大量內存。

同樣,如果我們把生成的列表返回,然後在主程序中接收,那麼引用依然存在,垃圾回收就不會被觸發,大量內存仍然被佔用著:

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() 函數並不是統計一個指針,而是要統計一個對象被引用的次數,所以最後一共會有八次引用。

理解引用這個概念後,引用釋放是一種非常自然和清晰的思想。相比 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
/<module>/<ipython-input-12-153e15063d8a>

到這裡,是不是覺得垃圾回收非常簡單呀?

我想,肯定有人覺得自己都懂了,那麼,如果此時有面試官問:引用次數為 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 的垃圾回收機制並沒有那麼弱。

Python 使用標記清除(mark-sweep)算法和分代收集(generational),來啟用針對循環引用的自動垃圾回收。你可能不太熟悉這兩個詞,這裡我簡單介紹一下。

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

當然,每次都遍歷全圖,對於 Python 而言是一種巨大的性能浪費。所以,在 Python 的垃圾回收實現中,mark-sweep 使用雙向鏈表維護了一個數據結構,並且只考慮容器類的對象(只有容器類對象才有可能產生循環引用)。具體算法這裡我就不再多講了,畢竟我們的重點是關注應用。

而分代收集算法,則是另一個優化手段。

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

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

學了這麼多,剛剛面試官的問題,你應該能回答得上來了吧!沒錯,引用計數是其中最簡單的實現,不過切記,引用計數並非充要條件,它只能算作充分非必要條件;至於其他的可能性,我們所講的循環引用正是其中一種。

調試內存洩漏

不過,雖然有了自動回收機制,但這也不是萬能的,難免還是會有漏網之魚。內存洩漏是我們不想見到的,而且還會嚴重影響性能。有沒有什麼好的調試手段呢?

答案當然是肯定的,接下來我就為你介紹一個“得力助手”。

它就是objgraph,一個非常好用的可視化引用關係的包。在這個包中,我主要推薦兩個函數,第一個是show_refs(),它可以生成清晰的引用關係圖。

通過下面這段代碼和生成的引用調用圖,你能非常直觀地發現,有兩個 list 互相引用,說明這裡極有可能引起內存洩露。這樣一來,再去代碼層排查就容易多了。

import objgraph
a = [1, 2, 3]
b = [4, 5, 6]
a.append(b)
b.append(a)
objgraph.show_refs([a])
解析 Python 垃圾回收機制

而另一個非常有用的函數,是 show_backrefs()。下面同樣為示例代碼和生成圖,你可以自己先閱讀一下:

import objgraph
a = [1, 2, 3]
b = [4, 5, 6]
a.append(b)
b.append(a)
objgraph.show_backrefs([a])
解析 Python 垃圾回收機制

相比剛才的引用調用圖,這張圖顯得稍微複雜一些。不過,我仍舊推薦你掌握它,因為這個 API 有很多有用的參數,比如層數限制(max_depth)、寬度限制(too_many)、輸出格式控制(filename output)、節點過濾(filter, extra_ignore)等。所以,建議你使用之前,先認真看一下文檔。

總結要強調下面這幾點:

  1. 垃圾回收是 Python 自帶的機制,用於自動釋放不會再用到的內存空間;
  2. 引用計數是其中最簡單的實現,不過切記,這只是充分非必要條件,因為循環引用需要通過不可達判定,來確定是否可以回收;
  3. Python 的自動回收算法包括標記清除和分代收集,主要針對的是循環引用的垃圾收集;
  4. 調試內存洩漏方面, objgraph 是很好的可視化分析工具。

思考題

最後給你留一道思考題。你能否自己實現一個垃圾回收判定算法呢?我的要求很簡單,輸入是一個有向圖,給定起點,表示程序入口點;給定有向邊,輸出不可達節點。


分享到:


相關文章: