隔離擴充套件模組

誰應該閱讀此內容

本指南是為 C-API 擴充套件的維護者編寫的,他們希望使該擴充套件在將 Python 本身用作庫的應用程式中更安全地使用。

背景

直譯器是 Python 程式碼執行的上下文。它包含配置(例如匯入路徑)和執行時狀態(例如匯入模組集)。

Python 支援在一個程序中執行多個直譯器。有兩種情況需要考慮——使用者可以執行直譯器

當將 Python 嵌入到庫中時,這兩種情況(以及它們的組合)將是最有用的。庫通常不應假定使用它們的應用程式,這包括假定程序範圍的“主 Python 直譯器”。

歷史上,Python 擴充套件模組不能很好地處理這個用例。許多擴充套件模組(甚至一些標準庫模組)使用程序範圍的全域性狀態,因為 C static 變數非常容易使用。因此,應該特定於直譯器的資料最終會在直譯器之間共享。除非擴充套件開發人員小心,否則在同一個程序中將模組載入到多個直譯器中時,很容易引入導致崩潰的邊界情況。

不幸的是,每個直譯器狀態並不容易實現。擴充套件作者在開發時往往不考慮多個直譯器,並且當前測試行為很麻煩。

引入 Per-Module 狀態

Python 的 C API 正在演進,以更好地支援更細粒度的每個模組狀態,而不是專注於每個直譯器狀態。這意味著 C 級別的資料應該附加到模組物件。每個直譯器建立自己的模組物件,使資料分離。為了測試隔離,甚至可以在單個直譯器中載入對應於單個擴充套件的多個模組物件。

每個模組狀態提供了一種簡單的方法來思考生命週期和資源所有權:擴充套件模組將在建立模組物件時初始化,並在釋放時清理。在這方面,模組就像任何其他 PyObject* 一樣;沒有“直譯器關閉時”的鉤子需要考慮——或忘記。

請注意,存在針對不同型別“全域性變數”的用例:程序、直譯器、執行緒或任務狀態。預設情況下,每個模組狀態仍然是可能的,但您應該將它們視為特殊情況:如果您需要它們,您應該給予它們額外的關注和測試。(請注意,本指南不涵蓋它們。)

隔離的模組物件

開發擴充套件模組時要記住的關鍵點是,可以從單個共享庫建立多個模組物件。例如

>>> import sys
>>> import binascii
>>> old_binascii = binascii
>>> del sys.modules['binascii']
>>> import binascii  # create a new module object
>>> old_binascii == binascii
False

根據經驗,這兩個模組應該完全獨立。所有特定於模組的物件和狀態都應該封裝在模組物件中,不與其他模組物件共享,並在模組物件解除分配時清理。由於這只是一個經驗法則,例外情況是可能的(參見管理全域性狀態),但它們需要更多思考和關注邊界情況。

雖然有些模組可以使用不那麼嚴格的限制,但隔離模組使設定明確的預期和指南變得更容易,這些預期和指南適用於各種用例。

意想不到的邊界情況

請注意,隔離模組確實會建立一些令人驚訝的邊界情況。最值得注意的是,每個模組物件通常不會與其他類似模組共享其類和異常。繼續從上面的示例,請注意 old_binascii.Errorbinascii.Error 是獨立的 物件。在以下程式碼中,異常捕獲

>>> old_binascii.Error == binascii.Error
False
>>> try:
...     old_binascii.unhexlify(b'qwertyuiop')
... except binascii.Error:
...     print('boo')
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
binascii.Error: Non-hexadecimal digit found

這是預期的。請注意,純 Python 模組的行為相同:這是 Python 工作方式的一部分。

目標是在 C 級別使擴充套件模組安全,而不是使駭客行為直觀。手動修改 sys.modules 算作駭客行為。

使模組在多個直譯器中安全

管理全域性狀態

有時,與 Python 模組關聯的狀態並非特定於該模組,而是特定於整個程序(或比模組“更全域性”的某個其他事物)。例如

  • readline 模組管理終端

  • 執行在電路板上的模組想要控制板載 LED

在這些情況下,Python 模組應該提供對全域性狀態的訪問,而不是擁有它。如果可能,編寫模組,使其多個副本可以獨立訪問狀態(以及其他庫,無論是 Python 還是其他語言)。如果不可能,請考慮顯式鎖定。

如果必須使用程序全域性狀態,避免多直譯器問題的最簡單方法是明確防止模組在每個程序中載入一次以上——參見選擇退出:限制每個程序一個模組物件

管理 Per-Module 狀態

要使用每個模組狀態,請使用多階段擴充套件模組初始化。這表示您的模組正確支援多個直譯器。

PyModuleDef.m_size 設定為正數,以請求模組本地的位元組儲存空間。通常,這將被設定為某個模組特定 struct 的大小,該結構可以儲存模組的所有 C 級狀態。特別是,您應該將指向類(包括異常,但不包括靜態型別)和設定(例如 csvfield_size_limit)的指標放在此處,這些是 C 程式碼正常執行所必需的。

備註

另一個選項是將狀態儲存在模組的 __dict__ 中,但您必須避免在使用者從 Python 程式碼修改 __dict__ 時崩潰。這通常意味著在 C 級別進行錯誤和型別檢查,這很容易出錯並且難以充分測試。

然而,如果 C 程式碼中不需要模組狀態,則僅將其儲存在 __dict__ 中是一個好主意。

如果模組狀態包含 PyObject 指標,則模組物件必須持有對這些物件的引用,並實現模組級鉤子 m_traversem_clearm_free。它們的作用類似於類的 tp_traversetp_cleartp_free。新增它們將需要一些工作並使程式碼更長;這是模組可以乾淨解除安裝的代價。

一個具有每個模組狀態的模組示例目前可在 xxlimited 中找到;檔案底部顯示了模組初始化示例。

選擇退出:限制每個程序一個模組物件

非負的 PyModuleDef.m_size 表示模組正確支援多個直譯器。如果您的模組尚未如此,您可以明確地使您的模組每個程序只能載入一次。例如

// A process-wide flag
static int loaded = 0;

// Mutex to provide thread safety (only needed for free-threaded Python)
static PyMutex modinit_mutex = {0};

static int
exec_module(PyObject* module)
{
    PyMutex_Lock(&modinit_mutex);
    if (loaded) {
        PyMutex_Unlock(&modinit_mutex);
        PyErr_SetString(PyExc_ImportError,
                        "cannot load module more than once per process");
        return -1;
    }
    loaded = 1;
    PyMutex_Unlock(&modinit_mutex);
    // ... rest of initialization
}

如果您的模組的 PyModuleDef.m_clear 函式能夠為將來的重新初始化做準備,它應該清除 loaded 標誌。在這種情況下,您的模組將不支援多個例項併發存在,但它將支援,例如,在 Python 執行時關閉 (Py_FinalizeEx()) 和重新初始化 (Py_Initialize()) 之後載入。

函式中訪問模組狀態

從模組級函式訪問狀態很簡單。函式將模組物件作為它們的第一個引數;為了提取狀態,您可以使用 PyModule_GetState

static PyObject *
func(PyObject *module, PyObject *args)
{
    my_struct *state = (my_struct*)PyModule_GetState(module);
    if (state == NULL) {
        return NULL;
    }
    // ... rest of logic
}

備註

如果沒有模組狀態,即 PyModuleDef.m_size 為零,PyModule_GetState 可能會返回 NULL 而不設定異常。在您自己的模組中,您可以控制 m_size,因此這很容易避免。

堆型別

傳統上,C 程式碼中定義的型別是靜態的;也就是說,直接在程式碼中定義並使用 PyType_Ready() 初始化的 static PyTypeObject 結構。

這些型別必然在程序之間共享。在模組物件之間共享它們需要注意它們擁有或訪問的任何狀態。為了限制可能的問題,靜態型別在 Python 級別是不可變的:例如,您不能設定 str.myattribute = 123

CPython 實現細節: 在直譯器之間共享真正不可變的物件是沒問題的,只要它們不提供對可變物件的訪問。然而,在 CPython 中,每個 Python 物件都有一個可變的實現細節:引用計數。引用計數的更改由 GIL 保護。因此,在直譯器之間共享任何 Python 物件的程式碼隱含地依賴於 CPython 當前的程序範圍 GIL。

由於它們是不可變且程序全域性的,靜態型別無法訪問“它們”的模組狀態。如果此類型別的任何方法需要訪問模組狀態,則該型別必須轉換為堆分配型別,或簡稱堆型別。這些更接近於 Python 的 class 語句建立的類。

對於新模組,預設使用堆型別是一個很好的經驗法則。

將靜態型別更改為堆型別

靜態型別可以轉換為堆型別,但請注意,堆型別 API 並非為靜態型別的“無損”轉換而設計——即,建立與給定靜態型別完全相同的型別。因此,在新的 API 中重寫類定義時,您可能會無意中更改一些細節(例如,可pickle性或繼承的槽)。請始終測試對您重要的細節。

特別注意以下兩點(但請注意,這並非詳盡列表)

定義堆型別

堆型別可以透過填充 PyType_Spec 結構(一個類的描述或“藍圖”)並呼叫 PyType_FromModuleAndSpec() 來構造新的類物件。

備註

其他函式,如 PyType_FromSpec(),也可以建立堆型別,但 PyType_FromModuleAndSpec() 將模組與類關聯起來,允許從方法訪問模組狀態。

類通常應該同時儲存在模組狀態(為了從 C 安全訪問)和模組的 __dict__ 中(為了從 Python 程式碼訪問)。

垃圾回收協議

堆型別的例項持有對其型別的引用。這確保了型別不會在其所有例項之前被銷燬,但也可能導致需要由垃圾收集器打破的引用迴圈。

為了避免記憶體洩漏,堆型別的例項必須實現垃圾收集協議。也就是說,堆型別應該

  • 具有 Py_TPFLAGS_HAVE_GC 標誌。

  • 使用 Py_tp_traverse 定義一個遍歷函式,該函式訪問型別(例如,使用 Py_VISIT(Py_TYPE(self)))。

請參考 Py_TPFLAGS_HAVE_GCtp_traverse 的文件以獲取額外考慮。

定義堆型別的 API 是有機發展的,導致其在當前狀態下使用起來有些笨拙。以下部分將指導您解決常見問題。

Python 3.8 及更低版本中的 tp_traverse

Python 3.9 中添加了從 tp_traverse 訪問型別的要求。如果您支援 Python 3.8 及更低版本,遍歷函式不能訪問型別,因此它必須更復雜

static int my_traverse(PyObject *self, visitproc visit, void *arg)
{
    if (Py_Version >= 0x03090000) {
        Py_VISIT(Py_TYPE(self));
    }
    return 0;
}

不幸的是,Py_Version 僅在 Python 3.11 中新增。作為替代,請使用

委託 tp_traverse

如果您的遍歷函式委託給其基類的 tp_traverse(或另一個型別),請確保 Py_TYPE(self) 只訪問一次。請注意,只有堆型別才期望在 tp_traverse 中訪問型別。

例如,如果你的遍歷函式包含

base->tp_traverse(self, visit, arg)

……並且 base 可能是一個靜態型別,那麼它也應該包含

if (base->tp_flags & Py_TPFLAGS_HEAPTYPE) {
    // a heap type's tp_traverse already visited Py_TYPE(self)
} else {
    if (Py_Version >= 0x03090000) {
        Py_VISIT(Py_TYPE(self));
    }
}

tp_newtp_clear 中不需要處理型別的引用計數。

定義 tp_dealloc

如果您的型別有一個自定義的 tp_dealloc 函式,它需要

為了在呼叫 tp_free 時保持型別有效,型別引用計數需要在例項解除分配之後遞減。例如

static void my_dealloc(PyObject *self)
{
    PyObject_GC_UnTrack(self);
    ...
    PyTypeObject *type = Py_TYPE(self);
    type->tp_free(self);
    Py_DECREF(type);
}

預設的 tp_dealloc 函式會執行此操作,因此如果您的型別重寫 tp_dealloc,則無需新增它。

不重寫 tp_free

堆型別的 tp_free 槽必須設定為 PyObject_GC_Del()。這是預設值;不要覆蓋它。

避免使用 PyObject_New

GC 跟蹤的物件需要使用 GC 感知函式分配。

如果您使用 PyObject_New()PyObject_NewVar()

  • 如果可能,獲取並呼叫型別的 tp_alloc 槽。也就是說,將 TYPE *o = PyObject_New(TYPE, typeobj) 替換為

    TYPE *o = typeobj->tp_alloc(typeobj, 0);
    

    o = PyObject_NewVar(TYPE, typeobj, size) 替換為相同,但使用 size 代替 0。

  • 如果上述操作不可行(例如,在自定義的 tp_alloc 中),請呼叫 PyObject_GC_New()PyObject_GC_NewVar()

    TYPE *o = PyObject_GC_New(TYPE, typeobj);
    
    TYPE *o = PyObject_GC_NewVar(TYPE, typeobj, size);
    

類中訪問模組狀態

如果您有一個使用 PyType_FromModuleAndSpec() 定義的型別物件,您可以呼叫 PyType_GetModule() 來獲取關聯的模組,然後呼叫 PyModule_GetState() 來獲取模組的狀態。

為了省去一些繁瑣的錯誤處理樣板程式碼,您可以使用 PyType_GetModuleState() 將這兩個步驟結合起來,結果是

my_struct *state = (my_struct*)PyType_GetModuleState(type);
if (state == NULL) {
    return NULL;
}

常規方法中訪問模組狀態

從類的方法訪問模組級狀態稍微複雜一些,但由於 Python 3.9 中引入的 API,這是可能的。要獲取狀態,您需要首先獲取定義類,然後從它獲取模組狀態。

最大的障礙是獲取定義方法時所在的類,或簡稱該方法的“定義類”。定義類可以引用它所屬的模組。

不要將定義類與 Py_TYPE(self) 混淆。如果方法在您的型別的子類上呼叫,Py_TYPE(self) 將引用該子類,該子類可能在與您的模組不同的模組中定義。

備註

以下 Python 程式碼可以說明這個概念。Base.get_defining_class 返回 Base,即使 type(self) == Sub

class Base:
    def get_type_of_self(self):
        return type(self)

    def get_defining_class(self):
        return __class__

class Sub(Base):
    pass

對於一個方法要獲取其“定義類”,它必須使用 METH_METHOD | METH_FASTCALL | METH_KEYWORDS 呼叫約定 和相應的 PyCMethod 簽名

PyObject *PyCMethod(
    PyObject *self,               // object the method was called on
    PyTypeObject *defining_class, // defining class
    PyObject *const *args,        // C array of arguments
    Py_ssize_t nargs,             // length of "args"
    PyObject *kwnames)            // NULL, or dict of keyword arguments

一旦您獲得了定義類,呼叫 PyType_GetModuleState() 來獲取其關聯模組的狀態。

例如:

static PyObject *
example_method(PyObject *self,
        PyTypeObject *defining_class,
        PyObject *const *args,
        Py_ssize_t nargs,
        PyObject *kwnames)
{
    my_struct *state = (my_struct*)PyType_GetModuleState(defining_class);
    if (state == NULL) {
        return NULL;
    }
    ... // rest of logic
}

PyDoc_STRVAR(example_method_doc, "...");

static PyMethodDef my_methods[] = {
    {"example_method",
      (PyCFunction)(void(*)(void))example_method,
      METH_METHOD|METH_FASTCALL|METH_KEYWORDS,
      example_method_doc}
    {NULL},
}

槽方法、getter 和 setter 中訪問模組狀態

備註

這是 Python 3.11 中的新功能。

槽方法——特殊方法的快速 C 等價物,例如 nb_add 用於 __add__tp_new 用於初始化——具有非常簡單的 API,不允許傳入定義類,與 PyCMethod 不同。對於使用 PyGetSetDef 定義的 getter 和 setter 也是如此。

在這些情況下,要訪問模組狀態,請使用 PyType_GetModuleByDef() 函式,並傳入模組定義。一旦您獲得了模組,呼叫 PyModule_GetState() 來獲取狀態

PyObject *module = PyType_GetModuleByDef(Py_TYPE(self), &module_def);
my_struct *state = (my_struct*)PyModule_GetState(module);
if (state == NULL) {
    return NULL;
}

PyType_GetModuleByDef() 透過搜尋方法解析順序(即所有超類)來查詢第一個具有相應模組的超類。

備註

在非常特殊的情況下(繼承鏈跨越由同一定義建立的多個模組),PyType_GetModuleByDef() 可能不會返回真正定義類的模組。但是,它始終會返回具有相同定義的模組,從而確保相容的 C 記憶體佈局。

模組狀態的生命週期

當模組物件被垃圾回收時,其模組狀態被釋放。對於指向模組狀態(的一部分)的每個指標,您必須持有對模組物件的引用。

通常這並不是一個問題,因為使用 PyType_FromModuleAndSpec() 建立的型別及其例項,都持有對模組的引用。但是,當您從其他地方(例如外部庫的回撥)引用模組狀態時,必須小心引用計數。

未解決的問題

圍繞每個模組狀態和堆型別的一些問題仍然懸而未決。

關於改進現狀的討論最好在c-api 標籤下的討論論壇上進行。

Per-Class 範圍

目前(截至 Python 3.11),不可能將狀態附加到單個型別,而不依賴於 CPython 實現細節(這些細節將來可能會改變——也許,諷刺地,是為了允許每個類範圍的正確解決方案)。

無損轉換為堆型別

堆型別 API 不是為靜態型別的“無損”轉換而設計的;也就是說,建立與給定靜態型別完全相同的型別。