C API 擴充套件對自由執行緒的支援

從 3.13 版本開始,CPython 支援在名為自由執行緒的配置中停用全域性直譯器鎖 (GIL) 執行。本文件描述瞭如何調整 C API 擴充套件以支援自由執行緒。

在 C 中識別自由執行緒構建

CPython C API 暴露了 Py_GIL_DISABLED 宏:在自由執行緒構建中,它被定義為 1,在常規構建中則未定義。您可以使用它來啟用僅在自由執行緒構建下執行的程式碼

#ifdef Py_GIL_DISABLED
/* code that only runs in the free-threaded build */
#endif

備註

在 Windows 上,此宏不會自動定義,而必須在編譯時指定給編譯器。sysconfig.get_config_var() 函式可用於確定當前執行的直譯器是否定義了該宏。

模組初始化

擴充套件模組需要明確指示它們支援在停用 GIL 的情況下執行;否則,匯入擴充套件將引發警告並在執行時啟用 GIL。

根據擴充套件是使用多階段初始化還是單階段初始化,有兩種方法可以指示擴充套件模組支援在停用 GIL 的情況下執行。

多階段初始化

使用多階段初始化(即 PyModuleDef_Init())的擴充套件應該在模組定義中新增一個 Py_mod_gil 槽。如果您的擴充套件支援舊版本的 CPython,您應該使用 PY_VERSION_HEX 檢查來保護該槽。

static struct PyModuleDef_Slot module_slots[] = {
    ...
#if PY_VERSION_HEX >= 0x030D0000
    {Py_mod_gil, Py_MOD_GIL_NOT_USED},
#endif
    {0, NULL}
};

static struct PyModuleDef moduledef = {
    PyModuleDef_HEAD_INIT,
    .m_slots = module_slots,
    ...
};

單階段初始化

使用單階段初始化(即 PyModule_Create())的擴充套件應該呼叫 PyUnstable_Module_SetGIL() 來指示它們支援在停用 GIL 的情況下執行。該函式僅在自由執行緒構建中定義,因此您應該使用 #ifdef Py_GIL_DISABLED 來保護呼叫,以避免在常規構建中出現編譯錯誤。

static struct PyModuleDef moduledef = {
    PyModuleDef_HEAD_INIT,
    ...
};

PyMODINIT_FUNC
PyInit_mymodule(void)
{
    PyObject *m = PyModule_Create(&moduledef);
    if (m == NULL) {
        return NULL;
    }
#ifdef Py_GIL_DISABLED
    PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
#endif
    return m;
}

通用 API 指南

大多數 C API 都是執行緒安全的,但也有一些例外。

  • 結構體欄位:如果 Python C API 物件或結構體中的欄位可能會被併發修改,則直接訪問這些欄位不是執行緒安全的。

  • :訪問器宏,例如 PyList_GET_ITEMPyList_SET_ITEM,以及使用 PySequence_Fast() 返回的物件的宏,例如 PySequence_Fast_GET_SIZE,不執行任何錯誤檢查或鎖定。如果容器物件可能被併發修改,這些宏不是執行緒安全的。

  • 借用引用:如果包含物件被併發修改,返回借用引用的 C API 函式可能不是執行緒安全的。有關更多資訊,請參閱借用引用部分。

容器執行緒安全

在自由執行緒構建中,像 PyListObjectPyDictObjectPySetObject 這樣的容器會執行內部鎖定。例如,PyList_Append() 在追加專案之前會鎖定列表。

PyDict_Next

一個顯著的例外是 PyDict_Next(),它不鎖定字典。如果字典可能被併發修改,您應該使用 Py_BEGIN_CRITICAL_SECTION 來保護字典在迭代期間。

Py_BEGIN_CRITICAL_SECTION(dict);
PyObject *key, *value;
Py_ssize_t pos = 0;
while (PyDict_Next(dict, &pos, &key, &value)) {
    ...
}
Py_END_CRITICAL_SECTION();

借用引用

一些 C API 函式返回借用引用。如果包含物件被併發修改,這些 API 不是執行緒安全的。例如,如果列表可能被併發修改,使用 PyList_GetItem() 是不安全的。

下表列出了一些借用引用 API 及其返回強引用的替代品。

借用引用 API

強引用 API

PyList_GetItem()

PyList_GetItemRef()

PyList_GET_ITEM()

PyList_GetItemRef()

PyDict_GetItem()

PyDict_GetItemRef()

PyDict_GetItemWithError()

PyDict_GetItemRef()

PyDict_GetItemString()

PyDict_GetItemStringRef()

PyDict_SetDefault()

PyDict_SetDefaultRef()

PyDict_Next()

無(請參閱PyDict_Next

PyWeakref_GetObject()

PyWeakref_GetRef()

PyWeakref_GET_OBJECT()

PyWeakref_GetRef()

PyImport_AddModule()

PyImport_AddModuleRef()

PyCell_GET()

PyCell_Get()

並非所有返回借用引用的 API 都有問題。例如,PyTuple_GetItem() 是安全的,因為元組是不可變的。類似地,並非所有上述 API 的使用都有問題。例如,PyDict_GetItem() 經常用於解析函式呼叫中的關鍵字引數字典;這些關鍵字引數字典實際上是私有的(其他執行緒無法訪問),因此在這種情況下使用借用引用是安全的。

其中一些函式是在 Python 3.13 中新增的。您可以使用 pythoncapi-compat 包為舊版本的 Python 提供這些函式的實現。

記憶體分配 API

Python 的記憶體管理 C API 在三個不同的分配域中提供函式:“raw”、“mem”和“object”。為了執行緒安全,自由執行緒構建要求只有 Python 物件使用物件域分配,並且所有 Python 物件都使用該域分配。這與之前的 Python 版本不同,之前的版本這只是一種最佳實踐,而不是硬性要求。

備註

在您的擴充套件中搜索 PyObject_Malloc() 的用法,並檢查分配的記憶體是否用於 Python 物件。使用 PyMem_Malloc() 來分配緩衝區而不是 PyObject_Malloc()

執行緒狀態和 GIL API

Python 提供了一組函式和宏來管理執行緒狀態和 GIL,例如

即使停用GIL,這些函式仍應在自由執行緒構建中使用,以管理執行緒狀態。例如,如果您在 Python 之外建立執行緒,則在呼叫 Python API 之前,必須呼叫 PyGILState_Ensure(),以確保執行緒具有有效的 Python 執行緒狀態。

您應該繼續在阻塞操作(例如 I/O 或鎖獲取)周圍呼叫 PyEval_SaveThread()Py_BEGIN_ALLOW_THREADS,以允許其他執行緒執行迴圈垃圾回收器

保護內部擴充套件狀態

您的擴充套件可能具有以前受 GIL 保護的內部狀態。您可能需要新增鎖定以保護此狀態。方法將取決於您的擴充套件,但一些常見模式包括

  • 快取:全域性快取是共享狀態的常見來源。考慮使用鎖來保護快取,或者如果快取對效能不關鍵,則在自由執行緒構建中停用它。

  • 全域性狀態:全域性狀態可能需要由鎖保護或移動到執行緒本地儲存。C11 和 C++11 提供 thread_local_Thread_local 用於執行緒本地儲存

臨界區

在自由執行緒構建中,CPython 提供了一種稱為“臨界區”的機制來保護原本受 GIL 保護的資料。雖然擴充套件作者可能不會直接與內部臨界區實現互動,但在使用某些 C API 函式或在自由執行緒構建中管理共享狀態時,瞭解其行為至關重要。

什麼是臨界區?

從概念上講,臨界區充當構建在簡單互斥鎖之上的死鎖避免層。每個執行緒維護一個活動臨界區堆疊。當執行緒需要獲取與臨界區關聯的鎖(例如,在呼叫執行緒安全的 C API 函式如 PyDict_SetItem() 時隱式獲取,或顯式使用宏時)時,它會嘗試獲取底層互斥鎖。

使用臨界區

使用臨界區的主要 API 是

這些宏必須成對使用,並且必須出現在同一個 C 作用域中,因為它們會建立一個新的區域性作用域。這些宏在非自由執行緒構建中是空操作,因此可以安全地新增到需要同時支援兩種構建型別的程式碼中。

臨界區的一個常見用途是在訪問物件的內部屬性時鎖定物件。例如,如果擴充套件型別具有內部計數字段,則可以在讀取或寫入該欄位時使用臨界區。

// read the count, returns new reference to internal count value
PyObject *result;
Py_BEGIN_CRITICAL_SECTION(obj);
result = Py_NewRef(obj->count);
Py_END_CRITICAL_SECTION();
return result;

// write the count, consumes reference from new_count
Py_BEGIN_CRITICAL_SECTION(obj);
obj->count = new_count;
Py_END_CRITICAL_SECTION();

臨界區的工作原理

與傳統鎖不同,臨界區不能保證在整個持續時間內獨佔訪問。如果執行緒在持有臨界區時會阻塞(例如,透過獲取另一個鎖或執行 I/O),則臨界區會暫時暫停——所有鎖都會釋放——然後在阻塞操作完成時恢復。

此行為類似於執行緒進行阻塞呼叫時 GIL 發生的情況。主要區別在於

  • 臨界區按物件而非全域性操作

  • 臨界區在每個執行緒內遵循堆疊規則(“begin”和“end”宏強制執行此規則,因為它們必須成對且在同一作用域內)

  • 臨界區在潛在的阻塞操作周圍自動釋放和重新獲取鎖

死鎖避免

臨界區透過兩種方式幫助避免死鎖

  1. 如果執行緒嘗試獲取已被另一個執行緒持有的鎖,它會首先暫停所有活動臨界區,暫時釋放它們的鎖。

  2. 當阻塞操作完成時,首先重新獲取最頂層的臨界區。

這意味著您不能依靠巢狀臨界區一次鎖定多個物件,因為內部臨界區可能會暫停外部臨界區。相反,請使用 Py_BEGIN_CRITICAL_SECTION2 同時鎖定兩個物件。

請注意,上面描述的鎖僅基於 PyMutex。臨界區實現不瞭解或不影響可能正在使用的其他鎖定機制,例如 POSIX 互斥鎖。另請注意,雖然阻塞任何 PyMutex 會導致臨界區暫停,但只有作為臨界區一部分的互斥鎖才會釋放。如果 PyMutex 在沒有臨界區的情況下使用,它將不會被釋放,因此不會獲得相同的死鎖避免。

重要注意事項

  • 臨界區可能會暫時釋放其鎖,允許其他執行緒修改受保護的資料。在可能阻塞的操作之後,請謹慎對資料狀態做出假設。

  • 因為鎖可以暫時釋放(暫停),所以進入臨界區不能保證在整個臨界區持續時間內獨佔訪問受保護的資源。如果臨界區內的程式碼呼叫了另一個會阻塞的函式(例如,獲取另一個鎖,執行阻塞 I/O),則執行緒透過臨界區持有的所有鎖都將被釋放。這類似於 GIL 在阻塞呼叫期間可以被釋放的方式。

  • 在任何給定時間,只有與最近進入(最頂層)臨界區相關聯的鎖才能保證被持有。外部、巢狀臨界區的鎖可能已被暫停。

  • 使用這些 API,您最多可以同時鎖定兩個物件。如果您需要鎖定更多物件,則需要重構您的程式碼。

  • 雖然如果您嘗試兩次鎖定同一個物件,臨界區不會死鎖,但對於此用例,它們效率低於專門構建的可重入鎖。

  • 使用 Py_BEGIN_CRITICAL_SECTION2 時,物件的順序不影響正確性(實現處理死鎖避免),但始終以一致的順序鎖定物件是一種好習慣。

  • 請記住,臨界區宏主要用於保護對可能涉及上述死鎖場景的內部 CPython 操作的 Python 物件 的訪問。為了保護純粹的內部擴充套件狀態,標準互斥鎖或其他同步原語可能更合適。

為自由執行緒構建編譯擴充套件

C API 擴充套件需要專門為自由執行緒構建編譯。輪子、共享庫和二進位制檔案由 t 字尾指示。

受限 C API 和穩定 ABI

自由執行緒構建目前不支援受限 C API 或穩定 ABI。如果您使用 setuptools 構建擴充套件並且當前設定了 py_limited_api=True,則可以在使用自由執行緒構建時使用 py_limited_api=not sysconfig.get_config_var("Py_GIL_DISABLED") 選擇不使用受限 API。

備註

您需要專門為自由執行緒構建單獨的輪子。如果您當前使用穩定 ABI,您可以繼續為多個非自由執行緒 Python 版本構建單個輪子。

Windows

由於官方 Windows 安裝程式的限制,您在從原始碼構建擴充套件時需要手動定義 Py_GIL_DISABLED=1

參見

移植擴充套件模組以支援自由執行緒:一個由社群維護的擴充套件作者移植指南。