隔離擴充套件模組

誰應該閱讀此文件

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

背景

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

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

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

從歷史上看,Python 擴充套件模組並沒有很好地處理這種情況。許多擴充套件模組(甚至一些 stdlib 模組)使用按程序全域性狀態,因為 C static 變數非常容易使用。因此,應該特定於直譯器的資料最終會在直譯器之間共享。除非擴充套件開發者小心,否則很容易引入邊緣情況,導致在同一程序中的多個直譯器中載入模組時崩潰。

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

進入按模組狀態

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 級別上安全,而不是使 hack 行為直觀。 “手動”修改 sys.modules 算作 hack。

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

管理全域性狀態

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

  • readline 模組管理終端

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

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

如果必須使用程序全域性狀態,避免多個直譯器問題的最簡單方法是顯式地阻止每個程序多次載入模組——請參閱 選擇退出:限制每個程序一個模組物件

管理按模組狀態

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

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 表示一個模組正確支援多個直譯器。如果您的模組尚未支援這種情況,您可以顯式地使您的模組在每個程序中僅載入一次。例如

static int loaded = 0;

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

從函式訪問模組狀態

從模組級函式訪問狀態很簡單。函式將模組物件作為它們的第一個引數;為了提取狀態,您可以使用 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 程式碼中定義的型別是 *靜態* 的;也就是說,static PyTypeObject 結構直接在程式碼中定義,並使用 PyType_Ready() 初始化。

這些型別必然在整個程序中共享。在模組物件之間共享它們需要注意它們擁有或訪問的任何狀態。為了限制可能出現的問題,靜態型別在 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

tp_traverse 訪問型別的要求是在 Python 3.9 中新增的。如果您支援 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() 建立的型別及其例項都保持對模組的引用。但是,當您從其他地方(例如,外部庫的回撥)引用模組狀態時,必須小心引用計數。

未解決的問題

圍繞每個模組的狀態和堆型別的幾個問題仍然存在。

關於改進這種情況的討論最好在 capi-sig 郵件列表上進行。

每個類的範圍

目前(截至 Python 3.11),在不依賴 CPython 實現細節的情況下(這在未來可能會發生變化——也許,具有諷刺意味的是,允許針對每個類的範圍的適當解決方案),無法將狀態附加到單個型別

無損轉換為堆型別

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