Python - 用範例學 weakref 模組

Posted on  Sep 6, 2023  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)

referrer_referent.png

如果想知道某個對象的參照者與被參照者,可以用 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

如果畫成圖的話,如下所示:

ref_self.png

如果你的程式有大量這種類別一直建立,最後肯定會造成記憶體洩漏,如果你不知道理解強參照、弱參照的分別,也就很難修正這個問題。

這個問題可以使用 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---

總結

weakref 確實是 1 個不太好理解的功能,我自己也是爬梳很多文章、影片,透過實作範例加上寫成文章的過程終於才理解 weakref, 而這個功能也確實對於 Python 的 GC 機制相當重要,運用得當可以有效避免記憶體洩漏的問題。

以上!

Happy Coding!

References

sys — System-specific parameters and functions

weakref — Weak references

python: what is weakref? (intermediate - advanced) anthony explains #366

對抗久坐職業傷害

研究指出每天增加 2 小時坐著的時間,會增加大腸癌、心臟疾病、肺癌的風險,也造成肩頸、腰背疼痛等常見問題。

然而對抗這些問題,卻只需要工作時定期休息跟伸展身體即可!

你想輕鬆改變現狀嗎?試試看我們的 PomodoRoll 番茄鐘吧! PomodoRoll 番茄鐘會根據你所設定的專注時間,定期建議你 1 項辦公族適用的伸展運動,幫助你打敗久坐所帶來的傷害!

追蹤新知

看完這篇文章了嗎?還意猶未盡的話,追蹤粉絲專頁吧!

我們每天至少分享 1 篇文章/新聞或者實用的軟體/工具,讓你輕鬆增廣見聞提升專業能力!如果你喜歡我們的文章,或是想了解更多特定主題的教學,歡迎到我們的粉絲專頁按讚、留言讓我們知道。你的鼓勵,是我們的原力!

贊助我們的創作

看完這篇文章了嗎? 休息一下,喝杯咖啡吧!

如果你覺得 MyApollo 有讓你獲得實用的資訊,希望能看到更多的技術分享,邀請你贊助我們一杯咖啡,讓我們有更多的動力與精力繼續提供高品質的文章,感謝你的支持!