Python 記憶體管理方式和垃圾回收演算法

王子健發表於2015-10-18

概要

在列表,元組,例項,類,字典和函式中存在迴圈引用問題。有 __del__ 方法的例項會以健全的方式被處理。給新型別新增GC支援是很容易的。支援GC的Python與常規的Python是二進位制相容的。

分代式回收能執行工作(目前是三個分代)。由 pybench 實測的結果是大約有百分之四的開銷。實際上所有的擴充套件模組都應該依然如故地正常工作(我不得不修改了標準發行版中的 new 和 cPickle 模組)。一個叫做 gc 的新模組馬上就可以用來除錯回收器和設定除錯選項。

回收器應該是跨平臺可移植的。Python 的補丁版本通過了所有的迴歸測試並且跑 Grail、Idle 和 Sketch 的時候沒有任何問題。

自 Python 2.0 和之後的版本,可移植的垃圾回收機制已經包括在其中了。垃圾回收預設是開啟的。請高興些吧!

為什麼我們需要垃圾回收?

目前版本的 Python 採用引用計數的方式來管理分配的記憶體。Python 的每個物件都有一個引用計數,這個引用計數表明了有多少物件在指向它。當這個引用計數為 0 時,該物件就釋放了。引用計數對於多數程式都工作地很好。然而,引用計數有一個本質上的缺陷,是由於迴圈引用引起的。迴圈引用最簡單的例子就是一個引用自身的物件。比如:

這個建立的列表的引用計數現在是 1。然而,因為它從 Python 內部已經無法訪問,並且可能沒法再被用到了,它應該被當作垃圾。在目前版本的 Python 中,這個列表永遠不會被釋放。

一般情況下迴圈引用不是一個好的程式設計實踐,並且幾乎總該被避免。然而,有時候很難避免製造迴圈引用,要麼則是程式設計師甚至沒有察覺到迴圈引用的問題。對於長期執行的程式,比如伺服器,這個問題特別令人煩惱。人們可不想他們的伺服器因為迴圈引用無法釋放訪問不到的物件而耗盡記憶體。對於大型程式,很難發現迴圈引用是怎麼創造出來的。

“傳統的”垃圾回收是怎樣的?

傳統的垃圾回收(比如標記-清除法或者停止-拷貝法)通常工作如下:

  1. 找到系統的根物件。根物件就像是全域性的環境(比如 Python 中的 __main__ 模組)和堆疊上的物件。
  2. 從這些物件搜尋所有的可以訪問的物件。這些物件都是“活躍”的。
  3. 釋放其他所有物件。

不幸的是這個方法不能用於當前版本的 Python。由於擴充套件模組的工作方式,Python 不能完全地確定根物件集合。如果根物件集合沒法被準確地確定,我們就有釋放仍然被引用的物件的風險。即使用其他方式設計擴充套件模組,也沒有可移植的方式來找到當前 C 堆疊上的物件。而且,引用計數提供了一些 Python 程式設計師已然期待的有關區域性性記憶體引用和終結語義的好處。最好是我們能夠找到一個即能使用引用計數,又能夠釋放迴圈引用的的辦法。

這個方法如何工作?

從概念上講,這個方法與傳統垃圾回收機制相反。這個方法試圖去找到所有的不可訪問物件,而不是去找所有的可訪問物件。這樣更加安全,因為如果這個演算法失敗了,起碼不會比不進行垃圾回收還要糟(不考慮我們浪費掉的時間和空間)。

因為我們仍然在用引用計數,垃圾回收器只需要找到迴圈引用。引用計數會處理其他型別垃圾。首先我們觀察到迴圈引用只能被容器物件創造。容器物件是可以包含其他物件的引用的物件。在Python中,列表、字典、例項、類和元祖都是容器物件的例子。整數和字串不是容器。通過這個發現,我們意識到非容器物件可以被垃圾回收忽略。這是一個有用的優化因為整數和字串這樣的應該比較輕快。

現在我們的想法是記錄所有的容器物件。有幾種方法可以做到,然而最好的一種辦法是利用雙向連結串列,連結串列中的物件結構中包含指標欄位。這樣就可以使物件從集合中快速插入刪除,而且不需要額外記憶體空間分配。當一個容器被建立,它就插入這個集合,被刪除時,就從集合中去除。

既然我們能夠得到所有的容器物件,我們怎麼找到迴圈引用呢?首先我們往容器物件中新增兩個指標外的另一個欄位。我們命名這個欄位 gc_refs。通過以下幾步我們可以找到迴圈引用:

  1. 對每個容器物件,設 gc_refs 的值為物件的引用計數。
  2. 對每個容器物件,找到它引用的其他容器物件並把它們的 gc_refs 值減一。
  3. 所有的 gc_refs 大於 1 的容器物件是被容器物件集合外的物件所引用的。我們不能釋放這些物件,所以我們把這些物件放到另一個集合。
  4. 被移走的物件所引用的物件也不能被釋放。我們把它們和它們能訪問到的物件都從目前集合移走。
  5. 在目前集合中的剩下的物件是僅被該集合中物件引用的(也就是說,他們無法被 Python 取到,也就是垃圾)。我們現在可以去釋放這些物件。

Finalizer的問題

我們的巨集偉計劃還有一個問題,就是使用 finalizer 的問題。Finalizer 就是在 Python 中例項的__del__方法。使用引用計數時,Finalizer 工作地不錯。當一個物件的引用計數降到 0 的時候,Finalizer 就在物件被釋放前呼叫了。對程式設計師來說這是直接明瞭且容易理解的。

垃圾回收的時候,呼叫 finalizer 就成了一個麻煩的問題,尤其是面對迴圈引用的問題時。如果在迴圈引用中的兩個物件都有 finalizer,該怎麼做?先呼叫哪個?在呼叫第一個 finalizer 之後,這個物件無法被釋放因為第二個 finalizer 還能取到它。

因為這個問題沒有好的解決辦法,被有 finalizer 的物件引用的迴圈是無法釋放的。相反的,這些物件被加進一個全域性的無法回收垃圾列表中。程式應該總是可以重新編寫來避免這個問題。作為最後的手段,程式可以讀取這個全域性列表並以一種對於當前應用有意義的方式釋放這些引用迴圈。

代價是什麼?

就像有些人說的,天底下沒有免費的午餐。然而,這種垃圾回收形式是相當廉價的。最大的代價之一是每各容器物件額外需要的三個字的記憶體空間。還有維護容器集合的開銷。對當前版本的垃圾收集器來說,基於 pybench 這個開銷大概是速度下降百分之四。

垃圾回收器目前記錄物件的三代資訊。通過調整引數,垃圾回收花費的時間可以想多小就多小。對一些應用來說,關掉自動垃圾回收並在執行時顯式呼叫也許是有意義的。然而,以預設的垃圾回收引數執行 pybench,垃圾回收花費的時間看起來並不大。顯而易見,大量分配容器物件的應用會引起更多的垃圾回收時間。

目前的補丁增加了一個新的配置項來啟用垃圾回收器。有垃圾回收器的 Python 與標準 Python 是二進位制相容的。如果這個選項是關閉的,對 Python 直譯器的工作就沒有影響。

我該怎麼使用它?

只要下載目前版本的 Python 就可以了。垃圾回收器已經包括在了 2.0 以後的版本中,並且預設是預設開啟的。如果你在用 Python 1.5.2 版,這裡有一個也許能工作的老版本的補丁。如果你用的是 Windows 平臺,你可以下載一個用來替代的 python15.dll

Boehm-Demers 保守垃圾回收

這個補丁增加了一些修改到 Python 1.5.2,以使用 Boehm-Demers 保守垃圾回收。但是你必須先打上這個補丁。依然是採用了引用計數。垃圾回收器只釋放引用計數沒有釋放的記憶體(即迴圈引用)。這樣應該效能最好。你需要:

這個補丁假設你安裝了 libgc.a,使得 -lgc 連結選項可用(/usr/local/lib 也應該可以)。如果你沒有這個庫,在編譯以前下載安裝。

目前,這個補丁只在 Linux 上測試過。在其 他Unix 機器上也許也會工作。在我的 Linux 機器上,GC 版本的 Python 通過了所有的迴歸測試。

其他

如果你試圖修復一個記憶體洩漏的 Python 程式,Tim Perter 的 Cyclops 模組也許也有用。在 Python FTP 站上能找到。

感謝 Narihiro Nakamura,這裡有一個本頁面的日文翻譯版本。

更新於: 2000年12月6日,星期三 10:36:38 -0800 Neil Schemenauer <nas@arctrix.com>

相關文章