首頁 > 軟體

Python中弱參照的神奇用法與原理詳解

2022-04-17 13:00:25

背景

開始討論弱參照( weakref )之前,我們先來看看什麼是弱參照?它到底有什麼作用?

假設我們有一個多執行緒程式,並行處理應用資料:

# 佔用大量資源,建立銷燬成本很高
class Data:
    def __init__(self, key):
        pass

應用資料 Data 由一個 key 唯一標識,同一個資料可能被多個執行緒同時存取。由於 Data 需要佔用很多系統資源,建立和消費的成本很高。我們希望 Data 在程式中只維護一個副本,就算被多個執行緒同時存取,也不想重複建立。

為此,我們嘗試設計一個快取中介軟體 Cacher :

import threading
# 資料快取
class Cacher:
    def __init__(self):
        self.pool = {}
        self.lock = threading.Lock()
    def get(self, key):
        with self.lock:
            data = self.pool.get(key)
            if data:
                return data
            self.pool[key] = data = Data(key)
            return data

Cacher 內部用一個 dict 物件來快取已建立的 Data 副本,並提供 get 方法用於獲取應用資料 Data 。get 方法獲取資料時先查快取字典,如果資料已存在,便直接將其返回;如果資料不存在,則建立一個並儲存到字典中。因此,資料首次被建立後就進入快取字典,後續如有其它執行緒同時存取,使用的都是快取中的同一個副本。

感覺非常不錯!但美中不足的是:Cacher 有資源洩露的風險!

因為 Data 一旦被建立後,就儲存在快取字典中,永遠都不會釋放!換句話講,程式的資源比如記憶體,會不斷地增長,最終很有可能會爆掉。因此,我們希望一個資料等所有執行緒都不再存取後,能夠自動釋放。

我們可以在 Cacher 中維護資料的參照次數, get 方法自動累加這個計數。於此同時提供一個 remove 新方法用於釋放資料,它先自減參照次數,並在參照次數降為零時將資料從快取欄位中刪除。

執行緒呼叫 get 方法獲取資料,資料用完後需要呼叫 remove 方法將其釋放。Cacher 相當於自己也實現了一遍參照計數法,這也太麻煩了吧!Python 不是內建了垃圾回收機制嗎?為什麼應用程式還需要自行實現呢?

衝突的主要癥結在於 Cacher 的快取字典:它作為一箇中介軟體,本身並不使用資料物件,因此理論上不應該對資料產生參照。那有什麼黑科技能夠在不產生參照的前提下,找到目標物件嗎?我們知道,賦值都是會產生參照的!

典型用法

這時,弱參照( weakref )隆重登場了!弱參照是一種特殊的物件,能夠在不產生參照的前提下,關聯目標物件。

# 建立一個資料
>>> d = Data('fasionchan.com')
>>> d
<__main__.Data object at 0x1018571f0>

# 建立一個指向該資料的弱參照
>>> import weakref
>>> r = weakref.ref(d)

# 呼叫弱參照物件,即可找到指向的物件
>>> r()
<__main__.Data object at 0x1018571f0>
>>> r() is d
True

# 刪除臨時變數d,Data物件就沒有其他參照了,它將被回收
>>> del d
# 再次呼叫弱參照物件,發現目標Data物件已經不在了(返回None)
>>> r()

這樣一來,我們只需將 Cacher 快取字典改成儲存弱參照,問題便迎刃而解!

import threading
import weakref
# 資料快取
class Cacher:
    def __init__(self):
        self.pool = {}
        self.lock = threading.Lock()
    def get(self, key):
        with self.lock:
            r = self.pool.get(key)
            if r:
                data = r()
                if data:
                    return data
            data = Data(key)
            self.pool[key] = weakref.ref(data)
            return data

由於快取字典只儲存 Data 物件的弱參照,因此 Cacher 不會影響 Data 物件的參照計數。當所有執行緒都用完資料後,參照計數就降為零因而被釋放。

實際上,用字典快取資料物件的做法很常用,為此 weakref 模組還提供了兩種只儲存弱參照的字典物件:

  • weakref.WeakKeyDictionary ,鍵只儲存弱參照的對映類(一旦鍵不再有強參照,鍵值對條目將自動消失);
  • weakref.WeakValueDictionary ,值只儲存弱參照的對映類(一旦值不再有強參照,鍵值對條目將自動消失);

因此,我們的資料快取字典可以採用 weakref.WeakValueDictionary 來實現,它的介面跟普通字典完全一樣。這樣我們不用再自行維護弱參照物件,程式碼邏輯更加簡潔明瞭:

import threading
import weakref
# 資料快取
class Cacher:
    def __init__(self):
        self.pool = weakref.WeakValueDictionary()
        self.lock = threading.Lock()
    def get(self, key):
        with self.lock:
            data = self.pool.get(key)
            if data:
                return data
            self.pool[key] = data = Data(key)
            return data

weakref 模組還有很多好用的工具類和工具函數,具體細節請參考官方檔案,這裡不再贅述。

工作原理

那麼,弱參照到底是何方神聖,為什麼會有如此神奇的魔力呢?接下來,我們一起揭下它的面紗,一睹真容!

>>> d = Data('fasionchan.com')

# weakref.ref 是一個內建型別物件
>>> from weakref import ref
>>> ref
<class 'weakref'>

# 呼叫weakref.ref型別物件,建立了一個弱參照範例物件
>>> r = ref(d)
>>> r
<weakref at 0x1008d5b80; to 'Data' at 0x100873d60>

經過前面章節,我們對閱讀內建物件原始碼已經輕車熟路了,相關原始碼檔案如下:

  • Include/weakrefobject.h 標頭檔案包含物件結構體和一些宏定義;
  • Objects/weakrefobject.c 原始檔包含弱參照型別物件及其方法定義;

我們先扒一扒弱參照物件的欄位結構,定義於 Include/weakrefobject.h 標頭檔案中的第 10-41 行:

typedef struct _PyWeakReference PyWeakReference;

/* PyWeakReference is the base struct for the Python ReferenceType, ProxyType,
 * and CallableProxyType.
 */
#ifndef Py_LIMITED_API
struct _PyWeakReference {
    PyObject_HEAD

    /* The object to which this is a weak reference, or Py_None if none.
     * Note that this is a stealth reference:  wr_object's refcount is
     * not incremented to reflect this pointer.
     */
    PyObject *wr_object;

    /* A callable to invoke when wr_object dies, or NULL if none. */
    PyObject *wr_callback;

    /* A cache for wr_object's hash code.  As usual for hashes, this is -1
     * if the hash code isn't known yet.
     */
    Py_hash_t hash;

    /* If wr_object is weakly referenced, wr_object has a doubly-linked NULL-
     * terminated list of weak references to it.  These are the list pointers.
     * If wr_object goes away, wr_object is set to Py_None, and these pointers
     * have no meaning then.
     */
    PyWeakReference *wr_prev;
    PyWeakReference *wr_next;
};
#endif

由此可見,PyWeakReference 結構體便是弱參照物件的肉身。它是一個定長物件,除固定頭部外還有 5 個欄位:

  • wr_object ,物件指標,指向被參照物件,弱參照根據該欄位可以找到被參照物件,但不會產生參照;
  • wr_callback ,指向一個可呼叫物件,當被參照的物件銷燬時將被呼叫;
  • hash ,快取被參照物件的雜湊值;
  • wr_prev 和 wr_next 分別是前後向指標,用於將弱參照物件組織成雙向連結串列;

結合程式碼中的註釋,我們知道:

  • 弱參照物件通過 wr_object 欄位關聯被參照的物件,如上圖虛線箭頭所示;
  • 一個物件可以同時被多個弱參照物件關聯,圖中的 Data 範例物件被兩個弱參照物件關聯;
  • 所有關聯同一個物件的弱參照,被組織成一個雙向連結串列,連結串列頭儲存在被參照物件中,如上圖實線箭頭所示;
  • 當一個物件被銷燬後,Python 將遍歷它的弱參照連結串列,逐一處理:
    • 將 wr_object 欄位設為 None ,弱參照物件再被呼叫將返回 None ,呼叫者便知道物件已經被銷燬了;
    • 執行回撥函數 wr_callback (如有);

由此可見,弱參照的工作原理其實就是設計模式中的 觀察者模式( Observer )。當物件被銷燬,它的所有弱參照物件都得到通知,並被妥善處理。

實現細節

掌握弱參照的基本原理,足以讓我們將其用好。如果您對原始碼感興趣,還可以再深入研究它的一些實現細節。

前面我們提到,對同一物件的所有弱參照,被組織成一個雙向連結串列,連結串列頭儲存在物件中。由於能夠建立弱參照的物件型別是多種多樣的,很難由一個固定的結構體來表示。因此,Python 在型別物件中提供一個欄位 tp_weaklistoffset ,記錄弱參照連結串列頭指標在範例物件中的偏移量。

由此一來,對於任意物件 o ,我們只需通過 ob_type 欄位找到它的型別物件 t ,再根據 t 中的 tp_weaklistoffset 欄位即可找到物件 o 的弱參照連結串列頭。

Python 在 Include/objimpl.h 標頭檔案中提供了兩個宏定義:

/* Test if a type supports weak references */
#define PyType_SUPPORTS_WEAKREFS(t) ((t)->tp_weaklistoffset > 0)

#define PyObject_GET_WEAKREFS_LISTPTR(o) 
    ((PyObject **) (((char *) (o)) + Py_TYPE(o)->tp_weaklistoffset))
  • PyType_SUPPORTS_WEAKREFS 用於判斷型別物件是否支援弱參照,僅當 tp_weaklistoffset 大於零才支援弱參照,內建物件 list 等都不支援弱參照;
  • PyObject_GET_WEAKREFS_LISTPTR 用於取出一個物件的弱參照連結串列頭,它先通過 Py_TYPE 宏找到型別物件 t ,再找通過 tp_weaklistoffset 欄位確定偏移量,最後與物件地址相加即可得到連結串列頭欄位的地址;

我們建立弱參照時,需要呼叫弱參照型別物件 weakref 並將被參照物件 d 作為引數傳進去。弱參照型別物件 weakref 是所有弱參照範例物件的型別,是一個全域性唯一的型別物件,定義在 Objects/weakrefobject.c 中,即:_PyWeakref_RefType(第 350 行)。

根據物件模型中學到的知識,Python 呼叫一個物件時,執行的是其型別物件中的 tp_call 函數。因此,呼叫弱參照型別物件 weakref 時,執行的是 weakref 的型別物件,也就是 type 的 tp_call 函數。tp_call 函數則回過頭來呼叫 weakref 的 tp_new 和 tp_init 函數,其中 tp_new 為範例物件分配記憶體,而 tp_init 則負責初始化範例物件。

回到 Objects/weakrefobject.c 原始檔,可以看到 PyWeakref_RefType 的 tp_new 欄位被初始化成 *weakref___new_*  (第 276 行)。該函數的主要處理邏輯如下:

  • 解析引數,得到被參照的物件(第 282 行);
  • 呼叫 PyType_SUPPORTS_WEAKREFS 宏判斷被參照的物件是否支援弱參照,不支援就拋異常(第 286 行);
  • 呼叫 GET_WEAKREFS_LISTPTR 行取出物件的弱參照連結串列頭欄位,為方便插入返回的是一個二級指標(第 294 行);
  • 呼叫 get_basic_refs 取出連結串列最前那個 callback 為空 基礎弱參照物件(如有,第 295 行);
  • 如果 callback 為空,而且物件存在 callback 為空的基礎弱參照,則複用該範例直接將其返回(第 296 行);
  • 如果不能複用,呼叫 tp_alloc 函數分配記憶體、完成欄位初始化,並插到物件的弱參照連結串列(第 309 行);
    • 如果 callback 為空,直接將其插入到連結串列最前面,方便後續複用(見第 4 點);
    • 如果 callback 非空,將其插到基礎弱參照物件(如有)之後,保證基礎弱參照位於連結串列頭,方便獲取;

當一個物件被回收後,tp_dealloc 函數將呼叫 PyObject_ClearWeakRefs 函數對它的弱參照進行清理。該函數取出物件的弱參照連結串列,然後逐個遍歷,清理 wr_object 欄位並執行 wr_callback 回撥函數(如有)。具體細節不再展開,有興趣的話可以自行查閱 Objects/weakrefobject.c 中的原始碼,位於 880 行。

好了,經過本節學習,我們徹底掌握了弱參照相關知識。弱參照可以在不產生參照計數的前提下,對目標物件進行管理,常用於框架和中介軟體中。弱參照看起來很神奇,其實設計原理是非常簡單的觀察者模式。弱參照物件建立後便插到一個由目標物件維護的連結串列中,觀察(訂閱)物件的銷燬事件。

總結

到此這篇關於Python中弱參照的神奇用法與原理的文章就介紹到這了,更多相關Python弱參照用法內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com