[Python進階]Python的記憶體垃圾回收機制

我們知道,Python 應用在執行時,需要從記憶體中劃出一段空間來存放臨時變數,計算完後再將結果存放到永久儲存介質。如果臨時變數所需的空間過大,會導致 OOM (Out of Memory) 記憶體錯誤,程式可能會被作業系統終止。

對於伺服器應用來說,為了設計永遠不中斷的系統,記憶體管理變得非常重要,否則容易引發記憶體洩漏 (Memory Leak)。

什麼是記憶體洩漏?

我們的應用程式在執行過程中會不斷向作業系統申請記憶體並釋放記憶體。記憶體洩漏指的是程式沒有釋放已不再使用的記憶體,導致記憶體的浪費,而非被攻擊。

Python 的記憶體管理

那麼 Python 是怎麼處理的呢?需要工程師們手動申請變數記憶體並清除嗎?答案是:不需要,Python 會自動找出不再使用的變數並釋放記憶體。

Reference Count(引用計數)

在 Python 中,一切皆為物件,每個物件都有多個pointer指向它。那麼如何知道這個物件不會再被使用呢?Python 使用引用計數(Reference Count),當引用計數為 0 時,代表這個物件不可達,不會再被使用了,這時需要被回收。

範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import os
import psutil

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 main():
show_memory_info('initial')
a = [i for i in range(10000000)]
show_memory_info('after a created')

main()
show_memory_info('finished')

# Output
# initial memory used: 7.30859375 MB
# after a created memory used: 404.09765625 MB
# finished memory used: 11.61328125 MB

show_memory_info 使用了 psutil library 來取得 process 所消耗的記憶體。程式剛開始執行時,佔用了約 7MB 的記憶體;當我們宣告了一個長度為一千萬的 list 之後,記憶體飆升至 404MB。然而在 main 函數結束後,變數 a 不再使用,所以引用計數變為 0,記憶體被回收,最後記憶體消耗降至 11MB。

另一個範例:全域變數

1
2
3
4
5
6
7
8
9
10
11
12
13
14
a= []
def main():
global a
show_memory_info('initial')
a = [i for i in range(10000000)]
show_memory_info('after a created')

main()
show_memory_info('finished')

# Output
# initial memory used: 7.28125 MB
# after a created memory used: 404.0859375 MB
# finished memory used: 404.0859375 MB

即使程式運行結束,記憶體使用量仍然很高。這顯示 Python 的記憶體垃圾回收機制並非萬無一失,在程式設計上仍需謹慎。

循環引用

還有一種情況是變數不再使用,但其引用計數並不為 0。這種情況就是循環引用,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def main():
show_memory_info('initial')
a = [i for i in range(10000000)]
b = [i for i in range(10000000)]
a.append(b)
b.append(a)
show_memory_info('after a created')

main()
show_memory_info('finished')

# Output
# initial memory used: 7.2890625 MB
# after a created memory used: 790.67578125 MB
# finished memory used: 790.67578125 MB

在此範例中,變數 a 和 b 互相引用,導致程式運行結束後記憶體未被回收。這只是簡單明顯的狀況,很多循環引用是很難被發現的。那麼我們應該怎麼做呢?我們可以手動調用垃圾回收:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import gc
def main():
show_memory_info('initial')
a = [i for i in range(10000000)]
b = [i for i in range(10000000)]
a.append(b)
b.append(a)
show_memory_info('after a created')

main()
gc.collect()
show_memory_info('finished')

# Output
# initial memory used: 7.37890625 MB
# after a created memory used: 790.03515625 MB
# finished memory used: 12.12890625 MB

Python 垃圾回收機制

針對循環引用,Python 使用標記清除 (mark-sweep) 和分代收集 (generational) 兩種演算法。以下簡單介紹一下:

標記清除演算法

我們可以用圖論來表達不可達概念。對於一個有向圖,從任意節點出發遍歷,若遍歷結束後有節點未被標記,則視為不可達,需要回收。然而每次遍歷全圖是巨大浪費,所以 Python 維護了一個雙向 linked-list,並且只維護 Container 類變數。

分代收集演算法

Python 將所有變數分為三代。剛創立的變數為第一代,垃圾回收後仍存在的變數會移到下一代。當某代變數超過某個閥值時,執行垃圾回收。

記憶體洩漏 debug

即使 Python 有強大的記憶體管理機制,難免仍有漏網之魚。Python 有個 library objgraph,可用於 debug:

1
2
3
4
5
6
7
8
9
10
import objgraph

a = [1, 2, 3]
b = [4, 5, 6]

a.append(b)
b.append(a)

objgraph.show_refs([a])
objgraph.show_backrefs([a])

總結

今天我們深入瞭解了 Python 垃圾回收機制,重點如下:

  • 垃圾回收在 Python 中用於釋放不再使用的記憶體給作業系統
  • Reference count 是常見方式,但不是唯一條件
  • Python 垃圾回收包含標記清除和分代收集演算法,主要針對循環引用
  • Debug 記憶體洩漏方面,objgraph 是個好工具