Python - 用範例學 weakref 模組
Last updated on Jul 28, 2024 by Amo Chen ‐ 7 min read
Python 是 1 個有垃圾回收機制(garbage collection, 或簡稱 GC)的程式語言,簡而言之是一種自動的記憶體管理機制,當某些記憶體空間沒有任何程式用到時,就會被回收,然後釋放這些記憶體空間,避免記憶體越用越少,最後導致程式錯誤、無法執行等問題。
GC 機制是為了減少開發者的負擔,例如 C 語言就需要手動釋放記憶體空間(詳細請參閱 free() 函式),所以忘記釋放記憶體空間造成記憶體洩漏(memory leak)的問題屢見不鮮,但如果交給 GC 的話,就可以讓開發者不太需要考慮記憶體管理的問題,增加開發的效率也降低犯錯的機率。
而 Python 的垃圾回收機制,是使用一種稱為 reference counting 的技術實作。
本文環境
- Python 3
Reference Counting
Python Reference counting 的大致運作方式,是每個物件實例(instance)都會有一個計數器,當有對象參照到某一個物件實例時,該物件實例的計數器就會加 1 ,如果不再參照到就會減 1 ,當這個計數器數值為零時,就會被 GC 機制回收,並釋放記憶體空間。
舉下列 a = A()
程式碼為例,對象 a
參照到 A()
,此時 A()
的計數器會是 1 ,所以 A()
不會被 GC 機制回收。
import gc
class A: pass
a1 = A()
我們稱 a
為參照者(referrer), A()
為被參照者(referent) 。
如果想知道某個對象的參照者與被參照者,可以用 gc 模組的 2 個方法:
取得 Reference Count
如果想取得 reference count, 可以使用 sys.getrefcount(), 例如下列範例:
import sys
class A: pass
a1 = A()
print('After `a1 = A()` =>', sys.getrefcount(a1))
a2 = a1
print('After `a2 = a1` =>', sys.getrefcount(a1))
a2 = None
print('After `a2 = None` =>', sys.getrefcount(a1))
上述範例執行結果如下,可以看到 A()
的計數器在經歷 2 次參照之後,數值來到 3, 再 a2 = None
解除參照之後,數值回到 2:
After `a1 = A()` => 2
After `a2 = a1` => 3
After `a2 = None` => 2
這時,我很好奇為什麼 a1 = A()
的參照數會是 2 ?原來使用 sys.getrefcount() 過程會有暫存的參照,導致參照數額外 +1 ,所以官方文件有清楚寫到 The count returned is generally one higher than you might expect , 這是千萬要注意的地方:
Return the reference count of the object. The count returned is generally one higher than you might expect, because it includes the (temporary) reference as an argument to getrefcount().
如果不信的話,可以實際用 Python 直譯器(interpreter),執行看看下列程式碼:
>>> class A:
>>> ... def __del__(self):
>>> ... print('Garbage collected!', self)
>>> ...
>>> A()
>>> <__main__.A object at 0x10153c550>
>>> _
>>> <__main__.A object at 0x10153c550>
>>> 3
>>> Garbage collected! <__main__.A object at 0x10153c550>
>>>
上述程式碼首先定義 1 個 A 類別,並實作 __del__
方法,這個方法會在 GC 執行時被呼叫,所以我們能夠知道它何時被回收,因為它被回收時會列印一個字串。
接著,我們直接用 A()
建立 1 個 A 類別的實例,由於它沒有任何參照者,我們預期它這時應該要被回收才對,但是事實上沒有。
這原因在於 Python 直譯器會使用 _
底線儲存上一次執行的結果,這造成有一個隱形的參照者存在,所以我們隨便輸入一個數值 3
,讓 _
底線的參照換成數值 3
,就成功觸發 A()
被回收囉,這證明 A()
在沒有參照者的情況下,會執行完之後直接被回收,如果有參照者的話,初始 reference count 會是 1 。
如果不想用直譯器玩的話,也可以直接試試以下 Python 程式碼:
class A:
def __del__(self):
print('Garbage collected!', self)
A()
print('---end---')
上述範例執行結果如下,可以看到 A()
在程式執行結束前就先被回收:
Garbage collected! <__main__.A object at 0x1049cbfa0>
---end---
強參照(Strong Reference)
In Python’s C API, a strong reference is a reference to an object which is owned by the code holding the reference. The strong reference is taken by calling Py_INCREF() when the reference is created and released with Py_DECREF() when the reference is deleted.
Python 預設使用的參照方式是強參照(strong reference),這種參照方式如前述般會對物件實例的計數器有 +1 的效果,解除參照時則會有 -1 的效果。
強參照在一些應用場景會容易有記憶體洩漏的問題,例如很常見的 Cache ,舉下列程式為例,我們建立 1 個 Image 類別,用來代表圖片資料,建立 3 個變數 a, b, c
各自參照 1 個 Image 實例,並將 3 個變數加入到 CACHE
之中,最後我們改變變數 b
的參照,將其指定為 None
,並試圖看看 CACHE
裡的變數 b
是否會消失:
from pprint import pprint
class Image:
def __init__(self, key):
self.key = key
self.body = f'body of {key}'
def __del__(self):
print('Garbage collected!', self.key, self)
CACHE = {}
def main():
a = Image('a')
CACHE[a.key] = a
b = Image('b')
CACHE[b.key] = b
c = Image('c')
CACHE[c.key] = c
b = None
print('CACHE:')
pprint(CACHE)
print('--- end of main() ---')
if __name__ == '__main__':
main()
print('--- end ---')
上述範例執行結果如下,可以看到 CACHE
裡的 b
依然存在,原因在於 CACHE
也是 1 個參照者,它直接參照到 <__main__.Image object at 0x103443d00>
,所以執行 b = None
並不會讓 CACHE
裡的 b
跟著消失,所以程式結束後才回收 a, b, c
3 個 Image 實例:
CACHE:
{'a': <__main__.Image object at 0x103443fa0>,
'b': <__main__.Image object at 0x103443d00>,
'c': <__main__.Image object at 0x1034ac850>}
--- end of main() ---
--- end ---
Garbage collected! b <__main__.Image object at 0x103443d00>
Garbage collected! c <__main__.Image object at 0x1034ac850>
Garbage collected! a <__main__.Image object at 0x103443fa0>
這種因為強參照造成記憶體空間沒被回收的情況,就是 1 種記憶體洩漏(memory leak)的原因。
這種情況下,我們得解除 Image('b') <__main__.Image object at 0x103443d00>
所有參照,才可以釋放記憶體:
class Image:
def __init__(self, key):
self.key = key
self.body = f'body of {key}'
def __del__(self):
print('Garbage collected!', self.key, self)
CACHE = {}
def main():
a = Image('a')
CACHE[a.key] = a
b = Image('b')
CACHE[b.key] = b
c = Image('c')
CACHE[c.key] = c
del CACHE[b.key]
b = None
print('--- end of main() ---')
if __name__ == '__main__':
main()
print('--- end ---')
上述範例執行結果如下,可以看到 Image('b') <__main__.Image object at 0x104d88850>
被提早回收:
Garbage collected! b <__main__.Image object at 0x104d88850>
--- end of main() ---
--- end ---
Garbage collected! a <__main__.Image object at 0x104d54550>
Garbage collected! c <__main__.Image object at 0x104d88a00>
弱參照(weak reference)
理解強參照之後,開始進入主題——「弱參照」。
像前述範例這樣,我們希望某個物件實例可以順利被回收,不要因為強參照的關係 reference count 無法歸零,導致佔用記憶體空間,這時就可以指定使用弱參照,弱參照的特點就是不會增加 reference count:
>>> import sys, weakref
>>> class A: pass
...
>>> a = A()
>>> sys.getrefcount(a)
2
>>> b = weakref.ref(a)
>>> sys.getrefcount(a)
2
>>> c = weakref.ref(a)
>>> sys.getrefcount(a)
2
弱參照的用法很簡單,只需要用 weakref.ref(<被參照者>)
即可,所以前述 CACHE 的範例,可以用 weakref.ref() 改成:
import weakref
from pprint import pprint
class Image:
def __init__(self, key):
self.key = key
self.body = f'body of {key}'
def __del__(self):
print('Garbage collected!', self.key, self)
CACHE = {}
def main():
a = Image('a')
CACHE[a.key] = weakref.ref(a)
b = Image('b')
CACHE[b.key] = weakref.ref(b)
c = Image('c')
CACHE[c.key] = weakref.ref(c)
b = None
print('CACHE:')
pprint(CACHE)
print('--- end of main() ---')
if __name__ == '__main__':
main()
print('--- end ---')
上述範例執行結果如下,可以看到當我們將 b
指向 None
之後, CACHE
裡的 b
值也變為 <weakref at 0x102c65d60; dead>
, 代表其參照對象已經不存在,同時 Image('b') <__main__.Image object at 0x102bc3d00>
也已經被回收:
Garbage collected! b <__main__.Image object at 0x102bc3d00>
CACHE:
{'a': <weakref at 0x102c4b810; to 'Image' at 0x102bc3fa0>,
'b': <weakref at 0x102c65d60; dead>,
'c': <weakref at 0x102c2cdb0; to 'Image' at 0x102c239d0>}
--- end of main() ---
Garbage collected! a <__main__.Image object at 0x102bc3fa0>
Garbage collected! c <__main__.Image object at 0x102c239d0>
--- end ---
改為 weakref 後,如果要判斷 CACHE 裡的 key 值能不能用,要變成呼叫該值,並檢查是否回傳 None
:
cache_ref = CACHE['b']
value = cache_ref()
if value is None:
print('No cache')
else:
print('Got cache', value)
WeakValueDictionary
前述使用 weakref() 的範例,在變數 b
改變參照時, CACHE
裡的 b
值雖然已經顯示為 dead
不可用,但實際 b
這個 key 還是存在,如果想要自動清乾淨的話,可以將 CACHE
改為使用 WeakValueDictionary ,它與 dict 最大的不同在於 value 只要沒有任何強參照,就會被自動清掉:
import weakref
from pprint import pprint
class Image:
def __init__(self, key):
self.key = key
self.body = f'body of {key}'
def __del__(self):
print('Garbage collected!', self.key, self)
CACHE = weakref.WeakValueDictionary()
def main():
a = Image('a')
CACHE[a.key] = a
b = Image('b')
CACHE[b.key] = b
c = Image('c')
CACHE[c.key] = c
b = None
print('CACHE:')
pprint(list(CACHE.items()))
print('--- end of main() ---')
if __name__ == '__main__':
main()
print('--- end ---')
上述範例執行結果如下,可以看到當我們將變數 b
指向 None
時,由於強參照已經不存在,所以 CACHE
裡的 b
key, value 被自動清除,記憶體也自動被回收:
Garbage collected! b <__main__.Image object at 0x100f5c940>
CACHE:
[('a', <__main__.Image object at 0x100ef3d00>),
('c', <__main__.Image object at 0x100f9f3a0>)]
--- end of main() ---
Garbage collected! a <__main__.Image object at 0x100ef3d00>
Garbage collected! c <__main__.Image object at 0x100f9f3a0>
--- end ---
與 WeakValueDictionary 類似的還有 WeakKeyDictionary, WeakKeyDictionary 是 key 沒有任何強參照時,會自動清除 key 與 value 。
除了 WeakValueDictionary 與 WeakKeyDictionary 之外, weakref 模組也提供 WeakSet, WeakMethod 等方法,大家可以挑選適合自己的方法使用。
Circular Reference
最後來聊聊 Circular Reference, 以下範例是 1 個經典的 Circular Reference, 原因在於 A
類別裡有個屬性 a
又參照了自己一次:
import sys
class A:
def __init__(self, key):
self.a = self
self.key = key
def __del__(self):
print('Garbage collected!', self.key)
a = A('a')
print("A's refcount:", sys.getrefcount(a))
a = None
print('---end---')
這樣的 Circular Reference 的類別建立實例之後,計數器一定是 2 起跳,就算我們改變參照,它還是不會被 GC 機制回收,所以上述範例執行結果如下,可以看到就算我們把變數 a
指向 None
, 它還是沒有被回收,而是等到程式結束之後才回收:
A's refcount: 3
---end---
Garbage collected! a
如果畫成圖的話,如下所示:
如果你的程式有大量這種類別一直建立,最後肯定會造成記憶體洩漏,如果你不知道理解強參照、弱參照的分別,也就很難修正這個問題。
這個問題可以使用 weakref.proxy() 解決,只要將屬性 a
改為使用 weakref.proxy()
指向 self 即可:
import sys
import weakref
class A:
def __init__(self, key):
self.a = weakref.proxy(self)
self.key = key
def __del__(self):
print('Garbage collected!', self.key)
a = A('a')
print("A's refcount:", sys.getrefcount(a))
a = None
print('---end---')
上述範例執行結果如下所示,可以看到 a = None
之後,就正確被 GC 機制回收了:
A's refcount: 2
Garbage collected! a
---end---
弱參照 Object 與 slots
我們用 Python 定義的 objects 都可以使用弱參照。
不過要注意的是,如果 class 內使用 __slots__
屬性的話,必須額外加入 __weakref__
,才可以使用弱參照,例如:
class A:
__slots__ = ['id', '__weakref__']
def __init__(self, id):
self.id = id
總結
weakref 確實是 1 個不太好理解的功能,我自己也是爬梳很多文章、影片,透過實作範例加上寫成文章的過程終於才理解 weakref, 而這個功能也確實對於 Python 的 GC 機制相當重要,運用得當可以有效避免記憶體洩漏的問題。
以上!
Happy Coding!
References
sys — System-specific parameters and functions
python: what is weakref? (intermediate - advanced) anthony explains #366